{"repo":"NousResearch/hermes-agent","public_id":"20883","display_label":"Context compression orphans tool messages, causing API errors","summary":"Multiple bugs and fixes concern context compression breaking tool call/result message adjacency, producing orphaned tool messages that trigger HTTP 400 errors on strict API providers. Work spans detection, deduplication of tool_call_ids, and repair of invalid message sequences before API requests.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":3,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":97203,"merged_at":"2026-08-28T14:51:24+00:00"},"resolution":{"state":"partially_resolved","by":"pr-58179,pr-97203"},"freshness":{"state":"verified","last_verified_at":"2026-08-28T14:51:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666665},"fix_edges":[{"issue_number":24328,"pr_number":97203,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-08-28T14:51:24+00:00"},{"issue_number":58168,"pr_number":58179,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-04T09:45:34+00:00"},{"issue_number":94704,"pr_number":97203,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-08-28T14:51:24+00:00"}],"members":[{"kind":"issue","number":20883,"title":"Bug: Context compression produces invalid tool_call/tool message ordering → HTTP 400 crash","url":null,"state":"closed"},{"kind":"issue","number":24328,"title":"Tool-message orphaning after message drops causes Moonshot AI / Kimi 400","url":null,"state":"closed"},{"kind":"issue","number":58168,"title":"Bug: Context compaction produces invalid message sequences (orphaned tool messages), breaking sessions permanently","url":null,"state":"closed"},{"kind":"pr","number":24330,"title":"fix: strict tool-message adjacency check for Moonshot AI / Kimi","url":null,"state":"open","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24405,"title":"fix(agent): adjacency-aware tool-message orphan detection","url":null,"state":"open","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":36157,"title":"fix(agent): enforce tool-result adjacency after compression","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":58179,"title":"fix(agent): match tool results on call_id||id in pre-request repair (#58168)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","tests/run_agent/test_message_sequence_repair.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -306,7 +306,13 @@ def _prepend_marker(tool_msg: dict) -> None:\n             try:\n                 json.loads(arguments)\n             except json.JSONDecodeError:\n-                tool_call_id = tool_call.get(\"id\")\n+                # Use the canonical ``call_id || id`` precedence so both the\n+                # scan for an existing tool result and any inserted stub key\n+                # on the same id the rest of the pipeline uses. Keying on bare\n+                # ``id`` here would fail to find a result built with ``call_id``\n+                # (Codex Responses format) and insert a duplicate stub that\n+                # itself becomes an orphan (#58168).\n+                tool_call_id = _ra().AIAgent._get_tool_call_id_static(tool_call) or None\n                 function_name = function.get(\"name\", \"?\")\n                 preview = arguments[:80]\n                 log.warning(","@@ -464,6 +470,21 @@ def _is_codex_interim(m: Dict) -> bool:\n     # Pass 1: drop stray tool messages that don't follow a known\n     # assistant tool_call_id. Uses a rolling set of known ids refreshed\n     # on each assistant message.\n+    #\n+    # Both ``id`` AND ``call_id`` are registered for every assistant\n+    # tool_call. In the Codex Responses API format the two differ\n+    # (``id`` = ``fc_...`` response-item id, ``call_id`` = ``call_...``\n+    # the function-call id), and a tool result's ``tool_call_id`` may be\n+    # matched against *either* depending on which code path built it\n+    # (the OpenAI-compatible path stores ``tc.id``; codex paths store\n+    # ``call_id``). Registering only ``id`` — as this pass did before —\n+    # made a valid tool result look orphaned whenever the assistant\n+    # tool_call carried a distinct ``call_id`` (or only ``call_id``); the\n+    # pass then dropped it, leaving the assistant tool_call unanswered and\n+    # producing an HTTP 400 on strict providers (DeepSeek, Kimi). Matching\n+    # on the *superset* of both keys achieves the same tolerance as\n+    # ``_get_tool_call_id_static``'s ``call_id || id`` — a match set must\n+    # accept every legitimate reference, not just the canonical one (#58168).\n     known_tool_ids: set = set()\n     filtered: List[Dict] = []\n     for msg in collapsed:","@@ -474,9 +495,12 @@ def _is_codex_interim(m: Dict) -> bool:\n         if role == \"assistant\":\n             known_tool_ids = set()\n             for tc in (msg.get(\"tool_calls\") or []):\n-                tc_id = tc.get(\"id\") if isinstance(tc, dict) else None\n-                if tc_id:\n-                    known_tool_ids.add(tc_id)\n+                if not isinstance(tc, dict):\n+                    continue\n+                for key in (\"id\", \"call_id\"):\n+                    tc_id = tc.get(key)\n+                    if tc_id:\n+                        known_tool_ids.add(tc_id)\n             filtered.append(msg)\n         elif role == \"tool\":\n             tc_id = msg.get(\"tool_call_id\")"]},{"file":"tests/run_agent/test_message_sequence_repair.py","hunks":["@@ -143,6 +143,103 @@ def test_repair_drops_stray_tool_with_unknown_tool_call_id():\n     assert all(m.get(\"role\") != \"tool\" for m in messages)\n \n \n+def test_repair_keeps_tool_matching_codex_call_id():\n+    \"\"\"A valid tool result must survive when the assistant tool_call carries a\n+    Codex-format ``call_id`` distinct from ``id`` and the result matches on\n+    ``call_id`` (#58168).\n+\n+    Before the fix, Pass 1 registered only ``tc.get(\"id\")`` (``fc_...``) in the\n+    known-id set, so a result keyed on ``call_id`` (``call_...``) looked\n+    orphaned and was dropped -- leaving the assistant tool_call unanswered and\n+    triggering an HTTP 400 on strict providers (DeepSeek, Kimi):\n+    \"Messages with role 'tool' must be a response to a preceding message with\n+    'tool_calls'\".\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"do it\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"fc_123\", \"call_id\": \"call_ABC\",\n+                         \"type\": \"function\",\n+                         \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_ABC\", \"content\": \"result\"},\n+        {\"role\": \"user\", \"content\": \"next\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs == 0\n+    assert [m[\"role\"] for m in messages] == [\"user\", \"assistant\", \"tool\", \"user\"]\n+    assert messages[2][\"tool_call_id\"] == \"call_ABC\"\n+\n+\n+def test_repair_keeps_tool_matching_only_call_id():\n+    \"\"\"Same as above but the assistant tool_call carries ONLY ``call_id`` (no\n+    ``id``). The result keyed on ``call_id`` must still be recognized (#58168).\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"do it\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"call_id\": \"call_XYZ\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_XYZ\", \"content\": \"result\"},\n+        {\"role\": \"user\", \"content\": \"next\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs == 0\n+    assert any(m.get(\"role\") == \"tool\" for m in messages)\n+\n+\n+def test_repair_keeps_tool_matching_id_when_call_id_also_present():\n+    \"\"\"When the assistant tool_call carries both ``id`` and ``call_id`` and the\n+    result matches on ``id`` (OpenAI-compatible builder path), it must be kept\n+    (#58168 -- both keys are registered, so either matches).\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"do it\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"fc_9\", \"call_id\": \"call_9\",\n+                         \"type\": \"function\",\n+                         \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"fc_9\", \"content\": \"result\"},\n+        {\"role\": \"user\", \"content\": \"next\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs == 0\n+    assert any(m.get(\"role\") == \"tool\" for m in messages)\n+\n+\n+def test_repair_still_drops_genuine_orphan_alongside_codex_pair():\n+    \"\"\"Negative control for #58168: registering both id and call_id must NOT\n+    over-relax orphan detection. A genuinely orphaned tool result (matching\n+    neither the id nor the call_id of any assistant tool_call) is still\n+    dropped, while the valid codex-format pair in the same window survives.\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"go\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"fc_1\", \"call_id\": \"call_1\",\n+                         \"type\": \"function\",\n+                         \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"valid\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_ORPHAN\", \"content\": \"stray\"},\n+        {\"role\": \"user\", \"content\": \"next\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs == 1\n+    tool_ids = [m[\"tool_call_id\"] for m in messages if m.get(\"role\") == \"tool\"]\n+    assert tool_ids == [\"call_1\"]\n+\n+\n def test_repair_leaves_valid_conversation_unchanged():\n     agent = _bare_agent()\n     messages = [\n"]}]},{"kind":"pr","number":79883,"title":"fix(agent): rebuild sanitize_api_messages for immediate tool adjacency","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24330","number":24330},{"kind":"pr","value":"#24405","number":24405},{"kind":"pr","value":"#24328","number":24328}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":97203,"title":"fix: strict positional providers no longer 400 on histories with displaced or replayed tool results — enforce tool_call/tool_result adjacency at both repair layers","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","tests/run_agent/test_message_sequence_repair.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}