diff --git a/run_agent.py b/run_agent.py index 54b0ebccb8..1dc9d058e0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3792,11 +3792,21 @@ class AIAgent: Ensures conversations are never lost, even on errors or early returns. """ + self._drop_trailing_empty_recovery_synthetic(messages) self._apply_persist_user_message_override(messages) self._session_messages = messages self._save_session_log(messages) self._flush_messages_to_session_db(messages, conversation_history) + def _drop_trailing_empty_recovery_synthetic(self, messages: List[Dict]) -> None: + """Remove private empty-response retry scaffolding from transcript tails.""" + while ( + messages + and isinstance(messages[-1], dict) + and messages[-1].get("_empty_recovery_synthetic") + ): + messages.pop() + def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None): """Persist any un-flushed messages to the SQLite session store. @@ -13706,6 +13716,7 @@ class AIAgent: # APIs reject as an invalid sequence. _nudge_msg = self._build_assistant_message(assistant_message, finish_reason) _nudge_msg["content"] = "(empty)" + _nudge_msg["_empty_recovery_synthetic"] = True messages.append(_nudge_msg) messages.append({ "role": "user", @@ -13714,6 +13725,7 @@ class AIAgent: "empty response. Please process the tool " "results above and continue with the task." ), + "_empty_recovery_synthetic": True, }) continue @@ -13816,6 +13828,7 @@ class AIAgent: # "(empty)" terminal. _turn_exit_reason = "empty_response_exhausted" reasoning_text = self._extract_reasoning(assistant_message) + self._drop_trailing_empty_recovery_synthetic(messages) assistant_msg = self._build_assistant_message(assistant_message, finish_reason) assistant_msg["content"] = "(empty)" messages.append(assistant_msg) @@ -13890,14 +13903,17 @@ class AIAgent: final_msg = self._build_assistant_message(assistant_message, finish_reason) - # Pop thinking-only prefill message(s) before appending - # the final response. This avoids consecutive assistant - # messages which break strict-alternation providers - # (Anthropic Messages API) and keeps history clean. + # Pop thinking-only prefill and empty-response retry + # scaffolding before appending the final response. These + # internal turns are only for the next API retry and should + # not become durable transcript context. while ( messages and isinstance(messages[-1], dict) - and messages[-1].get("_thinking_prefill") + and ( + messages[-1].get("_thinking_prefill") + or messages[-1].get("_empty_recovery_synthetic") + ) ): messages.pop() diff --git a/tests/run_agent/test_empty_response_recovery_persistence.py b/tests/run_agent/test_empty_response_recovery_persistence.py new file mode 100644 index 0000000000..59c606dadc --- /dev/null +++ b/tests/run_agent/test_empty_response_recovery_persistence.py @@ -0,0 +1,66 @@ +"""Regression tests for empty-response recovery transcript persistence.""" + +from run_agent import AIAgent + + +def _agent_with_stubbed_persistence(): + agent = AIAgent.__new__(AIAgent) + agent._persist_user_message_idx = None + agent._persist_user_message_override = None + agent._session_db = None + agent._session_messages = [] + agent.saved_session_logs = [] + agent.flushed_session_db_messages = [] + agent._save_session_log = lambda messages: agent.saved_session_logs.append( + [m.copy() for m in messages] + ) + agent._flush_messages_to_session_db = lambda messages, conversation_history=None: ( + agent.flushed_session_db_messages.append([m.copy() for m in messages]) + ) + return agent + + +def test_persist_session_strips_trailing_empty_recovery_scaffolding(): + agent = _agent_with_stubbed_persistence() + messages = [ + {"role": "user", "content": "run the task"}, + {"role": "tool", "content": "{}", "tool_call_id": "call_1"}, + { + "role": "assistant", + "content": "(empty)", + "_empty_recovery_synthetic": True, + }, + { + "role": "user", + "content": ( + "You just executed tool calls but returned an empty response. " + "Please process the tool results above and continue with the task." + ), + "_empty_recovery_synthetic": True, + }, + ] + + AIAgent._persist_session(agent, messages, conversation_history=[]) + + assert messages == [ + {"role": "user", "content": "run the task"}, + {"role": "tool", "content": "{}", "tool_call_id": "call_1"}, + ] + assert agent.saved_session_logs[-1] == messages + assert all(not msg.get("_empty_recovery_synthetic") for msg in messages) + + +def test_persist_session_keeps_real_terminal_empty_response(): + agent = _agent_with_stubbed_persistence() + messages = [ + {"role": "user", "content": "run the task"}, + {"role": "assistant", "content": "(empty)"}, + ] + + AIAgent._persist_session(agent, messages, conversation_history=[]) + + assert messages == [ + {"role": "user", "content": "run the task"}, + {"role": "assistant", "content": "(empty)"}, + ] + assert agent.saved_session_logs[-1] == messages