diff --git a/strix/agents/factory.py b/strix/agents/factory.py index b2fcbf08..613c1f1a 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -18,6 +18,7 @@ from pydantic import ValidationError from strix.agents.prompt import render_system_prompt from strix.config import load_settings +from strix.config.tool_call_arguments import describe_malformed_arguments from strix.tools.agents_graph.tools import ( agent_finish, create_agent, @@ -260,6 +261,10 @@ def _with_coerced_arguments(tool: FunctionTool) -> FunctionTool: nullish = tool.name.startswith(_QUERY_TOOL_PREFIXES) async def invoke(ctx: Any, raw_input: str) -> Any: + malformed = describe_malformed_arguments(tool.name, raw_input) + if malformed is not None: + logger.debug("Tool %s got malformed arguments; asking the model to re-issue", tool.name) + return malformed return await invoke_tool(ctx, _coerce_arguments(raw_input, schema, nullish=nullish)) tool.on_invoke_tool = invoke diff --git a/strix/config/tool_call_arguments.py b/strix/config/tool_call_arguments.py index e4db2e28..9169dcdc 100644 --- a/strix/config/tool_call_arguments.py +++ b/strix/config/tool_call_arguments.py @@ -22,6 +22,31 @@ from openai.types.responses import ResponseFunctionToolCall MALFORMED_ARGUMENTS_KEY = "malformed_arguments" +def describe_malformed_arguments(tool_name: str, arguments: str) -> str | None: + """Return a model-facing recovery message if ``arguments`` is not a JSON object. + + A tool call whose arguments do not parse is almost always one the stream cut + off (the server flushed a prefix of the JSON, or the client closed early), + so instead of the SDK's generic parse-error result the model is told the + call never ran and must be re-issued whole. + """ + if not arguments.strip(): + return None + try: + parsed = json.loads(arguments) + except ValueError as exc: + detail = str(exc) + else: + if isinstance(parsed, dict): + return None + detail = f"expected a JSON object, got {type(parsed).__name__}" + return ( + f"{tool_name}: the tool call was not executed because its arguments were " + f"truncated or otherwise not valid JSON ({detail}). The response was likely " + "cut off mid-call. Re-issue the call with complete, valid JSON arguments." + ) + + def repair_arguments(arguments: object) -> str | None: """Return replacement arguments a strict server accepts, or ``None`` if already valid.""" if not isinstance(arguments, str) or not arguments.strip(): diff --git a/tests/test_agent_factory_tool_arguments.py b/tests/test_agent_factory_tool_arguments.py index d7503944..4330ca4a 100644 --- a/tests/test_agent_factory_tool_arguments.py +++ b/tests/test_agent_factory_tool_arguments.py @@ -38,6 +38,20 @@ async def _roundtrip( _STRING = {"todos": {"type": "string"}} + + +@pytest.mark.asyncio +async def test_truncated_arguments_short_circuit_with_a_reissue_message() -> None: + captured: dict[str, str] = {} + wrapped = factory._with_coerced_arguments(_capturing_tool(captured, _STRING)) + + result = await wrapped.on_invoke_tool(cast("Any", None), '{"todos": "a, b') + + assert "not executed" in result + assert "Re-issue the call" in result + assert "raw_input" not in captured + + _ARRAY = {"tags": {"type": "array", "items": {"type": "string"}}} _NULLABLE_ARRAY = { "tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]} @@ -136,12 +150,14 @@ async def test_unknown_and_null_arguments_are_untouched() -> None: @pytest.mark.asyncio -async def test_non_object_payloads_pass_through_unchanged() -> None: +async def test_non_object_payloads_are_reported_instead_of_invoked() -> None: captured: dict[str, str] = {} wrapped = factory._with_coerced_arguments(_capturing_tool(captured, _ARRAY)) - assert await wrapped.on_invoke_tool(cast("Any", None), "not json") == "ok" - assert captured["raw_input"] == "not json" + result = await wrapped.on_invoke_tool(cast("Any", None), "not json") + + assert result.startswith("probe: the tool call was not executed") + assert "raw_input" not in captured @pytest.mark.asyncio diff --git a/tests/test_tool_call_arguments.py b/tests/test_tool_call_arguments.py index 5cd0224f..55631a78 100644 --- a/tests/test_tool_call_arguments.py +++ b/tests/test_tool_call_arguments.py @@ -25,6 +25,7 @@ from openai.types.responses import ResponseFunctionToolCall from strix.config.models import _NonStreamingModel, _TurnGuardModel from strix.config.tool_call_arguments import ( MALFORMED_ARGUMENTS_KEY, + describe_malformed_arguments, repair_arguments, repair_history_arguments, repair_input, @@ -244,3 +245,17 @@ def test_repair_input_returns_same_object_when_nothing_changes() -> None: assert repair_input(items) is items assert repair_input("plain prompt") == "plain prompt" + + +@pytest.mark.parametrize("arguments", ['{"cmd": "ls -la', "[1, 2]", "null", "not json"]) +def test_describe_malformed_arguments_tells_the_model_to_reissue(arguments: str) -> None: + message = describe_malformed_arguments("exec_command", arguments) + + assert message is not None + assert message.startswith("exec_command: the tool call was not executed") + assert "Re-issue the call" in message + + +@pytest.mark.parametrize("arguments", ["", " ", "{}", '{"cmd": "ls"}']) +def test_describe_malformed_arguments_accepts_objects_and_empty_input(arguments: str) -> None: + assert describe_malformed_arguments("exec_command", arguments) is None