Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
88 changes: 88 additions & 0 deletions tests/test_agent_loop_bad_json.py
Original file line number Diff line number Diff line change
@@ -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()