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 == ""