diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3431237fa..ed3a5a078 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1589,7 +1589,11 @@ class AgentLoop: elif isinstance(content, list): filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True) if not filtered: - continue + # Dropping the message would leave its assistant + # tool_call without a result; keep a placeholder. + filtered = [ + {"type": "text", "text": "[tool result omitted during persistence]"} + ] entry["content"] = filtered elif role == "user": if isinstance(content, str) and ContextBuilder._RUNTIME_CONTEXT_TAG in content: diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 4ce7e9527..a802712b6 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -1345,3 +1345,32 @@ async def test_turn_after_unanswered_user_keeps_tool_call_pairing(tmp_path: Path assert [m["role"] for m in persisted.messages] == [ "user", "user", "assistant", "tool", "assistant", ] + + +def test_save_turn_keeps_placeholder_for_empty_tool_result_blocks() -> None: + # Dropping the whole tool message would leave the assistant tool_call + # without a result, which strict APIs reject as firmly as orphans. + loop = _mk_loop() + session = Session(key="test:empty-tool-blocks") + + loop._save_turn( + session, + [ + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_empty", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + }], + }, + {"role": "tool", "tool_call_id": "call_empty", "name": "exec", "content": []}, + ], + skip=0, + ) + + assert [m["role"] for m in session.messages] == ["assistant", "tool"] + assert session.messages[1]["content"] == [ + {"type": "text", "text": "[tool result omitted during persistence]"} + ]