diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index eba037c4b..949e21116 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1592,13 +1592,13 @@ class AgentLoop: continue # skip empty assistant messages — they poison session context if role == "tool": tool_call_id = entry.get("tool_call_id") - if tool_call_id and str(tool_call_id) not in declared_tool_call_ids: + if not tool_call_id or str(tool_call_id) not in declared_tool_call_ids: # A tool result without a declared call violates the # OpenAI/Anthropic pairing contract and would poison # every future request built from this session (#4006). logger.warning( "Dropping orphaned tool result {} from session {} during persistence", - tool_call_id, + tool_call_id or "(missing id)", session.key, ) continue diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 20ea99694..fc0f49f5d 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -1406,6 +1406,23 @@ def test_save_turn_drops_orphaned_tool_results() -> None: assert [m["role"] for m in session.messages] == ["user", "assistant"] +def test_save_turn_drops_tool_results_without_tool_call_id() -> None: + loop = _mk_loop() + session = Session(key="test:missing-tool-call-id") + session.add_message("user", "hi") + + loop._save_turn( + session, + [ + {"role": "tool", "name": "exec", "content": "missing id"}, + {"role": "assistant", "content": "done"}, + ], + skip=0, + ) + + assert [m["role"] for m in session.messages] == ["user", "assistant"] + + def test_save_turn_keeps_tool_results_declared_in_prior_history() -> None: # Declarations may live in already-persisted history (e.g. a restored # runtime checkpoint), not only in the new-turn slice.