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
58 changes: 36 additions & 22 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -4411,34 +4411,48 @@ def _perform_api_call(next_api_kwargs):
pass

# Check for incomplete <REASONING_SCRATCHPAD> (opened but never closed)
# This means the model ran out of output tokens mid-reasoning — retry up to 2 times
if has_incomplete_scratchpad(assistant_message.content or ""):
# Recover structured calls; retry text-only truncation up to 2 times.
incomplete_scratchpad = has_incomplete_scratchpad(assistant_message.content or "")
if incomplete_scratchpad and assistant_message.tool_calls:
# A structured tool call is represented independently even
# when the adjacent free-form reasoning was truncated. Drop
# the unclosed tail before persistence/replay, then use the
# normal validation and approval path.
visible_prefix, _, _ = assistant_message.content.partition(
"<REASONING_SCRATCHPAD>"
)
assistant_message.content = visible_prefix.rstrip()
agent._buffer_vprint(
"⚠️ Incomplete <REASONING_SCRATCHPAD> detected; "
f"recovering {len(assistant_message.tool_calls)} structured tool call(s)"
)
elif incomplete_scratchpad:
agent._incomplete_scratchpad_retries += 1

agent._buffer_vprint("⚠️ Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)")

if agent._incomplete_scratchpad_retries <= 2:
agent._buffer_vprint(f"🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...")
# Don't add the broken message, just retry
continue
else:
# Max retries - discard this turn and save as partial
agent._flush_status_buffer()
agent._vprint(f"{agent.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.", force=True)
agent._incomplete_scratchpad_retries = 0
rolled_back_messages = agent._get_messages_up_to_last_assistant(messages)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
return {
"final_response": "Incomplete REASONING_SCRATCHPAD after 2 retries",
"messages": rolled_back_messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
"error": "Incomplete REASONING_SCRATCHPAD after 2 retries"
}

# Max retries - discard this turn and save as partial
agent._flush_status_buffer()
agent._vprint(f"{agent.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.", force=True)
agent._incomplete_scratchpad_retries = 0

rolled_back_messages = agent._get_messages_up_to_last_assistant(messages)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)

return {
"final_response": "Incomplete REASONING_SCRATCHPAD after 2 retries",
"messages": rolled_back_messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
"error": "Incomplete REASONING_SCRATCHPAD after 2 retries"
}

# Reset incomplete scratchpad counter on clean response
agent._incomplete_scratchpad_retries = 0
Expand Down
79 changes: 79 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4211,6 +4211,85 @@ def test_tool_call_none_args_verbose_logging_does_not_crash(self, agent):
assert result["final_response"] == "Done searching"
assert mock_handle_function_call.call_args.args[:2] == ("web_search", {})

def test_incomplete_scratchpad_preserves_structured_tool_call(self, agent):
self._setup_agent(agent)
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
resp1 = _mock_response(
content="Visible preface\n<REASONING_SCRATCHPAD>Need current data",
finish_reason="tool_calls",
tool_calls=[tc],
)
resp2 = _mock_response(content="Done searching", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [resp1, resp2]

with (
patch("run_agent.handle_function_call", return_value="search result") as mock_handle_function_call,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("search something")

assert result["final_response"] == "Done searching"
assert result["api_calls"] == 2
mock_handle_function_call.assert_called_once()
replay_messages = agent.client.chat.completions.create.call_args_list[1].kwargs["messages"]
recovered = next(msg for msg in replay_messages if msg.get("tool_calls"))
assert recovered["content"] == "Visible preface"
assert "REASONING_SCRATCHPAD" not in recovered["content"]

def test_incomplete_scratchpad_does_not_execute_non_object_tool_call(self, agent):
self._setup_agent(agent)
tc = _mock_tool_call(
name="web_search",
arguments="[]",
call_id="c1",
)
resp1 = _mock_response(
content="<REASONING_SCRATCHPAD>Need current data",
finish_reason="tool_calls",
tool_calls=[tc],
)
resp2 = _mock_response(content="Recovered", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [resp1, resp2]

with (
patch("run_agent.handle_function_call") as mock_handle_function_call,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("search something")

assert result["final_response"] == "Recovered"
assert result["api_calls"] == 2
mock_handle_function_call.assert_not_called()
replay_messages = agent.client.chat.completions.create.call_args_list[1].kwargs["messages"]
tool_result = next(msg for msg in replay_messages if msg.get("role") == "tool")
assert "Invalid tool arguments" in tool_result["content"]

def test_incomplete_scratchpad_without_tool_calls_still_fails_bounded(self, agent):
self._setup_agent(agent)
incomplete = _mock_response(
content="<REASONING_SCRATCHPAD>Still thinking",
finish_reason="stop",
)
agent.client.chat.completions.create.return_value = incomplete

with (
patch("run_agent.handle_function_call") as mock_handle_function_call,
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("hard question")

assert result["completed"] is False
assert result["partial"] is True
assert result["api_calls"] == 3
assert agent.client.chat.completions.create.call_count == 3
mock_handle_function_call.assert_not_called()

def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent):
self._setup_agent(agent)
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
Expand Down
Loading