From eb5727abdab6c225c8aafe7a6d961b82ec806dd2 Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 3 Jun 2026 01:14:37 -0400 Subject: [PATCH] fix(agent): coerce non-object tool-call arguments instead of crashing (#1370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native function/tool call whose `arguments` field is valid JSON but not an object — a bare array like ["ls -la"], or a string/number/bool/null — parsed fine in function_call_to_tool_block and then every branch called args.get(...), raising AttributeError ('list'/'str' object has no attribute 'get'). That propagated out of the streamed agent loop (no surrounding try/except at the call site in stream_agent_loop) and aborted the user's entire turn. Weaker and local models routinely emit malformed args like this. Coerce non-dict parsed arguments to {} (mirrors the existing empty-arguments behavior), so the tool runs with empty args instead of killing the stream. Adds tests/test_function_call_non_object_args.py covering array/string/number/ bool/null arguments — they fail before this change and pass after. --- src/tool_schemas.py | 8 +++++ tests/test_function_call_non_object_args.py | 37 +++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/test_function_call_non_object_args.py diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 4e0bd6e..70a446c 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -1074,6 +1074,14 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock logger.error(f"Failed to parse function call arguments for {name}: {arguments}") return None + # Some models emit valid JSON that isn't an object (e.g. a bare array + # ["ls -la"], string, or number) as the function arguments. Every branch + # below assumes a dict and calls args.get(...), so a non-dict would raise + # AttributeError and abort the whole agent stream. Coerce to {} instead. + if not isinstance(args, dict): + logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty") + args = {} + tool_type = _TOOL_NAME_MAP.get(name, name) # Allow MCP tools through (namespaced as mcp__serverid__toolname) diff --git a/tests/test_function_call_non_object_args.py b/tests/test_function_call_non_object_args.py new file mode 100644 index 0000000..a3ea995 --- /dev/null +++ b/tests/test_function_call_non_object_args.py @@ -0,0 +1,37 @@ +import sys +from unittest.mock import MagicMock + +# Clean up any mocks from previous tests to ensure we load real modules +for mod in ['src.agent_tools', 'src.tool_parsing', 'src.tool_schemas', 'src.tool_execution']: + sys.modules.pop(mod, None) + +# Mock heavy database/model dependencies before importing +for mod in [ + 'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative', + 'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression', + 'src.database', 'core.models', 'core.database', 'core.auth' +]: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + +import pytest +import src.agent_tools # noqa: F401 +from src.tool_schemas import function_call_to_tool_block + + +@pytest.mark.parametrize("arguments", [ + '["ls -la"]', # JSON array + '"ls -la"', # bare JSON string + '42', # JSON number + 'true', # JSON bool + 'null', # JSON null +]) +def test_non_object_arguments_do_not_crash(arguments): + """A native function call whose arguments are valid JSON but not an object + must not raise (it used to throw AttributeError: 'list' object has no + attribute 'get', aborting the entire agent stream).""" + block = function_call_to_tool_block("bash", arguments) + # Coerced to empty args -> empty bash command, but importantly NO crash. + assert block is not None + assert block.tool_type == "bash" + assert block.content == ""