fix: drop tool results missing call ids

maintainer edit: treat tool messages without tool_call_id as orphaned during session persistence so malformed results cannot survive into history.
This commit is contained in:
chengyongru 2026-06-12 11:23:36 +08:00 committed by Xubin Ren
parent eb25df9b49
commit 33e6da14d8
2 changed files with 19 additions and 2 deletions

View File

@ -1592,13 +1592,13 @@ class AgentLoop:
continue # skip empty assistant messages — they poison session context continue # skip empty assistant messages — they poison session context
if role == "tool": if role == "tool":
tool_call_id = entry.get("tool_call_id") 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 # A tool result without a declared call violates the
# OpenAI/Anthropic pairing contract and would poison # OpenAI/Anthropic pairing contract and would poison
# every future request built from this session (#4006). # every future request built from this session (#4006).
logger.warning( logger.warning(
"Dropping orphaned tool result {} from session {} during persistence", "Dropping orphaned tool result {} from session {} during persistence",
tool_call_id, tool_call_id or "(missing id)",
session.key, session.key,
) )
continue continue

View File

@ -1406,6 +1406,23 @@ def test_save_turn_drops_orphaned_tool_results() -> None:
assert [m["role"] for m in session.messages] == ["user", "assistant"] 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: def test_save_turn_keeps_tool_results_declared_in_prior_history() -> None:
# Declarations may live in already-persisted history (e.g. a restored # Declarations may live in already-persisted history (e.g. a restored
# runtime checkpoint), not only in the new-turn slice. # runtime checkpoint), not only in the new-turn slice.