diff --git a/agent_loop.py b/agent_loop.py index a744f0d26..400c98c06 100644 --- a/agent_loop.py +++ b/agent_loop.py @@ -67,8 +67,22 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, _hook('llm_after', locals()) if not response.tool_calls: tool_calls = [{'tool_name': 'no_tool', 'args': {}}] - else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id} - for tc in response.tool_calls] + else: + tool_calls = [] + for tc in response.tool_calls: + raw = tc.function.arguments + try: + args = json.loads(raw) + if not isinstance(args, dict): + raise TypeError("tool arguments must be a JSON object") + except (json.JSONDecodeError, TypeError): + raw_text = raw if isinstance(raw, str) else str(raw) + raw_len = len(raw_text) + raw_preview = raw_text[:800] + if raw_len > len(raw_preview): raw_preview += f"... [truncated, total={raw_len} chars]" + tool_calls.append({'tool_name': 'bad_json', 'args': {'msg': f"Tool {tc.function.name} arguments are invalid JSON or are not a JSON object: {raw_preview!r}. Retry with a valid JSON object."}, 'id': tc.id}) + continue + tool_calls.append({'tool_name': tc.function.name, 'args': args, 'id': tc.id}) tool_results = []; next_prompts = set(); exit_reason = {} for ii, tc in enumerate(tool_calls): diff --git a/tests/test_agent_loop_bad_json.py b/tests/test_agent_loop_bad_json.py new file mode 100644 index 000000000..df2dc6f48 --- /dev/null +++ b/tests/test_agent_loop_bad_json.py @@ -0,0 +1,88 @@ +import unittest +from types import SimpleNamespace + +from agent_loop import BaseHandler, exhaust, agent_runner_loop + + +class StubClient: + def __init__(self, arguments): + self.arguments = arguments + self.last_tools = "" + + def chat(self, messages, tools): + response = SimpleNamespace( + content="", + tool_calls=[SimpleNamespace( + id="call-1", + function=SimpleNamespace(name="file_read", arguments=self.arguments), + )], + ) + + def gen(): + if False: + yield None + return response + + return gen() + + +class StubHandler(BaseHandler): + def __init__(self): + self.parent = SimpleNamespace(task_dir=None) + self._done_hooks = [] + self.next_prompts = [] + + def do_file_read(self, args, response): + if False: + yield None + return SimpleNamespace(data=None, next_prompt="ok", should_exit=False) + + def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): + self.next_prompts.append(next_prompt) + return next_prompt + + +class AgentLoopBadJsonTests(unittest.TestCase): + def _run(self, arguments): + handler = StubHandler() + result = exhaust(agent_runner_loop( + StubClient(arguments), + "system", + "user", + handler, + tools_schema=[], + max_turns=1, + verbose=False, + )) + return result, handler + + def test_malformed_tool_arguments_are_returned_to_model_for_retry(self): + result, handler = self._run('{"path":') + self.assertEqual(result, {"result": "MAX_TURNS_EXCEEDED"}) + self.assertEqual(len(handler.next_prompts), 1) + self.assertIn("file_read", handler.next_prompts[0]) + self.assertIn("invalid JSON", handler.next_prompts[0]) + + def test_non_object_json_tool_arguments_are_returned_for_retry(self): + for arguments in ("null", "[]", '"path"', "1", "true"): + with self.subTest(arguments=arguments): + result, handler = self._run(arguments) + self.assertEqual(result, {"result": "MAX_TURNS_EXCEEDED"}) + self.assertEqual(len(handler.next_prompts), 1) + self.assertIn("file_read", handler.next_prompts[0]) + self.assertIn("JSON object", handler.next_prompts[0]) + + def test_malformed_argument_retry_preview_is_bounded(self): + arguments = '{"path":"' + ('x' * 20_000) + result, handler = self._run(arguments) + + self.assertEqual(result, {"result": "MAX_TURNS_EXCEEDED"}) + self.assertEqual(len(handler.next_prompts), 1) + prompt = handler.next_prompts[0] + self.assertLess(len(prompt), 2_000) + self.assertIn("truncated", prompt.lower()) + self.assertIn(str(len(arguments)), prompt) + + +if __name__ == "__main__": + unittest.main()