From fa3ab2ffd0f18069b8ef10117d4e1e4e4c5a9bd8 Mon Sep 17 00:00:00 2001 From: nightq Date: Tue, 30 Jun 2026 01:22:02 -0700 Subject: [PATCH] fix: normalize tool_call_id whitespace in sanitizer _sanitize_api_messages() compared raw tool_call_id strings without stripping whitespace. When assistant-side IDs and tool-result IDs diverged due to surrounding whitespace, valid tool results were treated as orphaned and replaced with [Result unavailable] stub placeholders. Strip whitespace in _get_tool_call_id_static() (both call_id/id paths, dict and object) and at the two result_call_id comparison sites in sanitize_api_messages(). Adds regression tests for preserved-whitespace results and orphaned-whitespace removal. Closes #9999 --- agent/agent_runtime_helpers.py | 4 ++-- run_agent.py | 4 ++-- tests/run_agent/test_agent_guardrails.py | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 7a65d3f2e5e..0b04eb38c83 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2163,7 +2163,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] result_call_ids: set = set() for msg in messages: if msg.get("role") == "tool": - cid = msg.get("tool_call_id") + cid = (msg.get("tool_call_id") or "").strip() if cid: result_call_ids.add(cid) @@ -2172,7 +2172,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] if orphaned_results: messages = [ m for m in messages - if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results) + if not (m.get("role") == "tool" and (m.get("tool_call_id") or "").strip() in orphaned_results) ] _ra().logger.debug( "Pre-call sanitizer: removed %d orphaned tool result(s)", diff --git a/run_agent.py b/run_agent.py index 1ded29f3679..467744c5a1c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3429,8 +3429,8 @@ class AIAgent: def _get_tool_call_id_static(tc) -> str: """Extract call ID from a tool_call entry (dict or object).""" if isinstance(tc, dict): - return tc.get("call_id", "") or tc.get("id", "") or "" - return getattr(tc, "call_id", "") or getattr(tc, "id", "") or "" + return (tc.get("call_id", "") or tc.get("id", "") or "").strip() + return (getattr(tc, "call_id", "") or getattr(tc, "id", "") or "").strip() @staticmethod def _get_tool_call_name_static(tc) -> str: diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py index b222b3320e2..eb89cdda9c0 100644 --- a/tests/run_agent/test_agent_guardrails.py +++ b/tests/run_agent/test_agent_guardrails.py @@ -108,6 +108,30 @@ class TestSanitizeApiMessages: assert len(out) == 2 assert out[1]["tool_call_id"] == "c6" + def test_tool_result_with_leading_whitespace_preserved(self): + """Tool result IDs with leading/trailing whitespace should match assistant call IDs.""" + msgs = [ + {"role": "assistant", "tool_calls": [assistant_dict_call("functions.cronjob:24")]}, + tool_result(" functions.cronjob:24"), # leading whitespace + ] + out = AIAgent._sanitize_api_messages(msgs) + # Should NOT inject a stub — the tool result is valid after stripping + assert len(out) == 2 + assert out[1]["role"] == "tool" + assert out[1]["content"] == "ok" + + def test_truly_orphaned_with_whitespace_still_removed(self): + """Truly orphaned tool results with whitespace should still be removed.""" + msgs = [ + {"role": "assistant", "tool_calls": [assistant_dict_call("c_valid")]}, + tool_result(" c_ORPHAN "), # whitespace + no matching call + ] + out = AIAgent._sanitize_api_messages(msgs) + assert len(out) == 2 # assistant + stub for c_valid, orphan removed + tool_msgs = [m for m in out if m["role"] == "tool"] + assert len(tool_msgs) == 1 + assert tool_msgs[0]["tool_call_id"] == "c_valid" + # --------------------------------------------------------------------------- # Phase 2a — _cap_delegate_task_calls