Skip to content
Closed
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
57 changes: 41 additions & 16 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,46 @@ def _strip_tool_suffix(s: str) -> str | None:



def _classify_tool_call_orphans(
messages: List[Dict[str, Any]],
) -> tuple:
"""Return (surviving_call_ids, result_call_ids, orphaned_results, missing_results).

Collects tool-call IDs from both assistant ``tool_calls`` (using the
``call_id || id`` resolution that ``_get_tool_call_id_static`` uses)
and from ``tool`` messages, then classifies orphans:

- ``orphaned_results``: tool messages whose ``tool_call_id`` has no
matching assistant tool_call in the window.
- ``missing_results``: assistant tool_calls whose results were dropped
(no matching ``tool`` message found).

This is the single source of truth for orphan detection. The caller
decides whether to insert stubs (pre-API sanitizer) or strip orphans
(context compressor) — but the *detection* logic is shared so dedup
and id-resolution rules never drift between the two sites (#58357).
"""
surviving_call_ids: set = set()
for msg in messages:
if msg.get("role") == "assistant":
for tc in msg.get("tool_calls") or []:
cid = _ra().AIAgent._get_tool_call_id_static(tc)
if cid:
surviving_call_ids.add(cid)

result_call_ids: set = set()
for msg in messages:
if msg.get("role") == "tool":
cid = (msg.get("tool_call_id") or "").strip()
if cid:
result_call_ids.add(cid)

orphaned_results = result_call_ids - surviving_call_ids
missing_results = surviving_call_ids - result_call_ids
return surviving_call_ids, result_call_ids, orphaned_results, missing_results



def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fix orphaned tool_call / tool_result pairs before every LLM call.

Expand Down Expand Up @@ -2471,23 +2511,9 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
elif isinstance(tc, dict):
tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"}

surviving_call_ids: set = set()
for msg in messages:
if msg.get("role") == "assistant":
for tc in msg.get("tool_calls") or []:
cid = _ra().AIAgent._get_tool_call_id_static(tc)
if cid:
surviving_call_ids.add(cid)

result_call_ids: set = set()
for msg in messages:
if msg.get("role") == "tool":
cid = (msg.get("tool_call_id") or "").strip()
if cid:
result_call_ids.add(cid)
_sv, _rs, orphaned_results, missing_results = _classify_tool_call_orphans(messages)

# 1. Drop tool results with no matching assistant call
orphaned_results = result_call_ids - surviving_call_ids
if orphaned_results:
messages = [
m for m in messages
Expand All @@ -2499,7 +2525,6 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
)

# 2. Inject stub results for calls whose result was dropped
missing_results = surviving_call_ids - result_call_ids
if missing_results:
patched: List[Dict[str, Any]] = []
for msg in messages:
Expand Down
21 changes: 3 additions & 18 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2187,23 +2187,11 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,
silently dropped by the repair pass, re-exposing the original orphans.
Stripping at the source avoids this entire class of mismatch.
"""
surviving_call_ids: set = set()
for msg in messages:
if msg.get("role") == "assistant":
for tc in msg.get("tool_calls") or []:
cid = self._get_tool_call_id(tc)
if cid:
surviving_call_ids.add(cid)
from agent.agent_runtime_helpers import _classify_tool_call_orphans

result_call_ids: set = set()
for msg in messages:
if msg.get("role") == "tool":
cid = msg.get("tool_call_id")
if cid:
result_call_ids.add(cid)
_sv, _rs, orphaned_results, missing_results = _classify_tool_call_orphans(messages)

# 1. Remove tool results whose call_id has no matching assistant tool_call
orphaned_results = result_call_ids - surviving_call_ids
if orphaned_results:
messages = [
m for m in messages
Expand All @@ -2213,10 +2201,7 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,
logger.info("Compression sanitizer: removed %d orphaned tool result(s)", len(orphaned_results))

# 2. Strip orphaned tool_calls from assistant messages whose results
# were dropped. Stripping is preferred over inserting stub results
# because stubs can be dropped by downstream repair_message_sequence
# when call_id != id (Codex Responses API format), re-exposing orphans.
missing_results = surviving_call_ids - result_call_ids
# were dropped.
if missing_results:
for msg in messages:
if msg.get("role") != "assistant":
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"wyuebei@gmail.com": "wyuebei-cloud", # PR #56640 salvage (hermes journey: replace GNU-only %-d strftime with dt.day for Windows)
"yingwaizhiying@gmail.com": "msh01", # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).
"danilo@falcao.org": "danilofalcao", # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)
"ishengeqi@163.com": "isheng-eqi",
"huanshan5195@users.noreply.github.com": "huanshan5195", # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level)
"infinitycrew39@gmail.com": "infinitycrew39", # PR #56431 salvage (honor live vLLM context limits on local endpoints)
"jonathan.kovacs999@gmail.com": "CocaKova", # PR #57692 salvage (cron: run jobs under the profile secret scope so get_secret does not fail-close with UnscopedSecretError under profile isolation)
Expand Down
61 changes: 61 additions & 0 deletions tests/run_agent/test_message_sequence_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,3 +770,64 @@ def test_sanitize_preserves_populated_tool_calls():
out = sanitize_api_messages(list(messages))
assistant = [m for m in out if m.get("role") == "assistant"][0]
assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_Z"]


# ── _classify_tool_call_orphans ─────────────────────────────────────────

def test_classify_orphans_empty():
from agent.agent_runtime_helpers import _classify_tool_call_orphans
sv, rs, orphaned, missing = _classify_tool_call_orphans([])
assert sv == set()
assert rs == set()
assert orphaned == set()
assert missing == set()


def test_classify_orphans_clean_pair():
from agent.agent_runtime_helpers import _classify_tool_call_orphans
messages = [
{"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
]
sv, rs, orphaned, missing = _classify_tool_call_orphans(messages)
assert sv == {"call_1"}
assert rs == {"call_1"}
assert orphaned == set()
assert missing == set()


def test_classify_orphans_detects_orphaned_result():
from agent.agent_runtime_helpers import _classify_tool_call_orphans
messages = [
{"role": "tool", "tool_call_id": "orphan_1", "content": "no matching call"},
]
sv, rs, orphaned, missing = _classify_tool_call_orphans(messages)
assert orphaned == {"orphan_1"}
assert missing == set()


def test_classify_orphans_detects_missing_result():
from agent.agent_runtime_helpers import _classify_tool_call_orphans
messages = [
{"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}]},
]
sv, rs, orphaned, missing = _classify_tool_call_orphans(messages)
assert orphaned == set()
assert missing == {"call_1"}


def test_classify_orphans_mixed():
from agent.agent_runtime_helpers import _classify_tool_call_orphans
messages = [
{"role": "assistant", "tool_calls": [
{"id": "call_A", "type": "function", "function": {"name": "f", "arguments": "{}"}},
{"id": "call_B", "type": "function", "function": {"name": "g", "arguments": "{}"}},
]},
{"role": "tool", "tool_call_id": "call_A", "content": "ok"},
{"role": "tool", "tool_call_id": "call_C", "content": "orphan"},
]
sv, rs, orphaned, missing = _classify_tool_call_orphans(messages)
assert sv == {"call_A", "call_B"}
assert rs == {"call_A", "call_C"}
assert orphaned == {"call_C"}
assert missing == {"call_B"}
Loading