diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2f1fecbac9..1bfd508f91 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -6045,8 +6045,8 @@ This compaction should PRIORITISE preserving all information related to the focu SimpleNamespace), for logging/display only. Matching logic must use :meth:`_tool_call_id_variants` instead — see its docstring.""" 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 _tool_call_id_variants(tc) -> set: @@ -6092,7 +6092,7 @@ This compaction should PRIORITISE preserving all information related to the focu 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: # Expand alias spellings on the RESULT side too — a # composite ``call|item`` tool_call_id must match a diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index ede4840a6c..c052c3fb1a 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -3619,3 +3619,60 @@ class TestPreLlmFeasibilityCheck: feasibility_skip=compressor._last_feasibility_skip, ) assert compressor._fallback_compression_streak == 1 + + +class TestSanitizeToolPairsWhitespace: + """_sanitize_tool_pairs must strip whitespace from tool_call_id before + comparing, matching the fix applied to agent_runtime_helpers.py in + commit fa3ab2ffd. Without stripping, a valid tool result whose + tool_call_id has surrounding whitespace is misclassified as orphaned + and silently replaced with a [Result unavailable] stub. + """ + + def _make(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + return ContextCompressor(model="test/model", quiet_mode=True, + protect_first_n=2, protect_last_n=2) + + def _assistant(self, call_id): + return { + "role": "assistant", "content": "", + "tool_calls": [{"id": call_id, "type": "function", + "function": {"name": "f", "arguments": "{}"}}], + } + + def test_leading_whitespace_on_result_id_preserved(self): + c = self._make() + msgs = [ + self._assistant("call_abc"), + {"role": "tool", "tool_call_id": " call_abc", "content": "ok"}, + ] + out = c._sanitize_tool_pairs(msgs) + tool_msgs = [m for m in out if m.get("role") == "tool"] + assert len(tool_msgs) == 1 + assert tool_msgs[0]["content"] == "ok", "valid result must not be treated as orphaned" + + def test_trailing_whitespace_on_result_id_preserved(self): + c = self._make() + msgs = [ + self._assistant("call_xyz"), + {"role": "tool", "tool_call_id": "call_xyz ", "content": "data"}, + ] + out = c._sanitize_tool_pairs(msgs) + tool_msgs = [m for m in out if m.get("role") == "tool"] + assert len(tool_msgs) == 1 + assert tool_msgs[0]["content"] == "data" + + def test_truly_orphaned_still_removed(self): + """Whitespace-trimmed ID that still has no match must be removed. + The assistant's call_real has no matching result, so a stub is + inserted in its place — the original orphaned entry must be gone.""" + c = self._make() + msgs = [ + self._assistant("call_real"), + {"role": "tool", "tool_call_id": " call_orphan ", "content": "stale"}, + ] + out = c._sanitize_tool_pairs(msgs) + tool_call_ids = [m.get("tool_call_id") for m in out if m.get("role") == "tool"] + assert "call_orphan" not in tool_call_ids, "genuinely orphaned result must be removed" + assert " call_orphan " not in tool_call_ids, "original whitespace form must also be gone"