# NousResearch/hermes-agent · Context compression orphans tool messages, causing API errors

## Open now

- 0 issues, 2 PRs open on GitHub.
- PR #24330: fix: strict tool-message adjacency check for Moonshot AI / Kimi
- PR #24405: fix(agent): adjacency-aware tool-message orphan detection

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.

## TL;DR

- 9 member(s): 0 open issue(s), 3 closed issue(s), 2 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #97203. Verify it addresses your case.
- **Recommended:** read PR #97203 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-58179,pr-97203`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.19`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #20883: Bug: Context compression produces invalid tool_call/tool message ordering → HTTP 400 crash
- #24328: Tool-message orphaning after message drops causes Moonshot AI / Kimi 400
- #58168: Bug: Context compaction produces invalid message sequences (orphaned tool messages), breaking sessions permanently

## Prior & competing PRs

- **#58179: fix(agent): match tool results on call_id||id in pre-request repair (#58168)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/agent_runtime_helpers.py`, `tests/run_agent/test_message_sequence_repair.py`
  - fix hunks:
  - `agent/agent_runtime_helpers.py`

    ```diff
    @@ -306,7 +306,13 @@ def _prepend_marker(tool_msg: dict) -> None:
                 try:
                     json.loads(arguments)
                 except json.JSONDecodeError:
    -                tool_call_id = tool_call.get("id")
    +                # Use the canonical ``call_id || id`` precedence so both the
    +                # scan for an existing tool result and any inserted stub key
    +                # on the same id the rest of the pipeline uses. Keying on bare
    +                # ``id`` here would fail to find a result built with ``call_id``
    +                # (Codex Responses format) and insert a duplicate stub that
    +                # itself becomes an orphan (#58168).
    +                tool_call_id = _ra().AIAgent._get_tool_call_id_static(tool_call) or None
                     function_name = function.get("name", "?")
                     preview = arguments[:80]
                     log.warning(
    ```

    ```diff
    @@ -464,6 +470,21 @@ def _is_codex_interim(m: Dict) -> bool:
         # Pass 1: drop stray tool messages that don't follow a known
         # assistant tool_call_id. Uses a rolling set of known ids refreshed
         # on each assistant message.
    +    #
    +    # Both ``id`` AND ``call_id`` are registered for every assistant
    +    # tool_call. In the Codex Responses API format the two differ
    +    # (``id`` = ``fc_...`` response-item id, ``call_id`` = ``call_...``
    +    # the function-call id), and a tool result's ``tool_call_id`` may be
    +    # matched against *either* depending on which code path built it
    +    # (the OpenAI-compatible path stores ``tc.id``; codex paths store
    +    # ``call_id``). Registering only ``id`` — as this pass did before —
    +    # made a valid tool result look orphaned whenever the assistant
    +    # tool_call carried a distinct ``call_id`` (or only ``call_id``); the
    +    # pass then dropped it, leaving the assistant tool_call unanswered and
    +    # producing an HTTP 400 on strict providers (DeepSeek, Kimi). Matching
    +    # on the *superset* of both keys achieves the same tolerance as
    +    # ``_get_tool_call_id_static``'s ``call_id || id`` — a match set must
    +    # accept every legitimate reference, not just the canonical one (#58168).
         known_tool_ids: set = set()
         filtered: List[Dict] = []
         for msg in collapsed:
    ```

    ```diff
    @@ -474,9 +495,12 @@ def _is_codex_interim(m: Dict) -> bool:
             if role == "assistant":
                 known_tool_ids = set()
                 for tc in (msg.get("tool_calls") or []):
    -                tc_id = tc.get("id") if isinstance(tc, dict) else None
    -                if tc_id:
    -                    known_tool_ids.add(tc_id)
    +                if not isinstance(tc, dict):
    +                    continue
    +                for key in ("id", "call_id"):
    +                    tc_id = tc.get(key)
    +                    if tc_id:
    +                        known_tool_ids.add(tc_id)
                 filtered.append(msg)
             elif role == "tool":
                 tc_id = msg.get("tool_call_id")
    ```
  - `tests/run_agent/test_message_sequence_repair.py`

    ```diff
    @@ -143,6 +143,103 @@ def test_repair_drops_stray_tool_with_unknown_tool_call_id():
         assert all(m.get("role") != "tool" for m in messages)
     
     
    +def test_repair_keeps_tool_matching_codex_call_id():
    +    """A valid tool result must survive when the assistant tool_call carries a
    +    Codex-format ``call_id`` distinct from ``id`` and the result matches on
    +    ``call_id`` (#58168).
    +
    +    Before the fix, Pass 1 registered only ``tc.get("id")`` (``fc_...``) in the
    +    known-id set, so a result keyed on ``call_id`` (``call_...``) looked
    +    orphaned and was dropped -- leaving the assistant tool_call unanswered and
    +    triggering an HTTP 400 on strict providers (DeepSeek, Kimi):
    +    "Messages with role 'tool' must be a response to a preceding message with
    +    'tool_calls'".
    +    """
    +    agent = _bare_agent()
    +    messages = [
    +        {"role": "user", "content": "do it"},
    +        {"role": "assistant", "content": "",
    +         "tool_calls": [{"id": "fc_123", "call_id": "call_ABC",
    +                         "type": "function",
    +                         "function": {"name": "x", "arguments": "{}"}}]},
    +        {"role": "tool", "tool_call_id": "call_ABC", "content": "result"},
    +        {"role": "user", "content": "next"},
    +    ]
    +
    +    repairs = AIAgent._repair_message_sequence(agent, messages)
    +
    +    assert repairs == 0
    +    assert [m["role"] for m in messages] == ["user", "assistant", "tool", "user"]
    +    assert messages[2]["tool_call_id"] == "call_ABC"
    +
    +
    +def test_repair_keeps_tool_matching_only_call_id():
    +    """Same as above but the assistant tool_call carries ONLY ``call_id`` (no
    +    ``id``). The result keyed on ``call_id`` must still be recognized (#58168).
    +    """
    +    agent = _bare_agent()
    +    messages = [
    +        {"role": "user", "content": "do it"},
    +        {"role": "assistant", "content": "",
    +         "tool_calls": [{"call_id": "call_XYZ", "type": "function",
    +                         "function": {"name": "x", "arguments": "{}"}}]},
    +        {"role": "tool", "tool_call_id": "call_XYZ", "content": "result"},
    +        {"role": "user", "content": "next"},
    +    ]
    +
    +    repairs = AIAgent._repair_message_sequence(agent, messages)
    +
    +    assert repairs == 0
    +    assert any(m.get("role") == "tool" for m in messages)
    +
    +
    +def test_repair_keeps_tool_matching_id_when_call_id_also_present():
    +    """When the assistant tool_call carries both ``id`` and ``call_id`` and the
    +    result matches on ``id`` (OpenAI-compatible builder path), it must be kept
    +    (#58168 -- both keys are registered, so either matches).
    +    """
    +    agent = _bare_agent()
    +    messages = [
    +        {"role": "user", "content": "do it"},
    +        {"role": "assistant", "content": "",
    +         "tool_calls": [{"id": "fc_9", "call_id": "call_9",
    +                         "type": "function",
    +                         "function": {"name": "x", "arguments": "{}"}}]},
    +        {"role": "tool", "tool_call_id": "fc_9", "content": "result"},
    +        {"role": "user", "content": "next"},
    +    ]
    +
    +    repairs = AIAgent._repair_message_sequence(agent, messages)
    +
    +    assert repairs == 0
    +    assert any(m.get("role") == "tool" for m in messages)
    +
    +
    +def test_repair_still_drops_genuine_orphan_alongside_codex_pair():
    +    """Negative control for #58168: registering both id and call_id must NOT
    +    over-relax orphan detection. A genuinely orphaned tool result (matching
    +    neither the id nor the call_id of any assistant tool_call) is still
    +    dropped, while the valid codex-format pair in the same window survives.
    +    """
    +    agent = _bare_agent()
    +    messages = [
    +        {"role": "user", "content": "go"},
    +        {"role": "assistant", "content": "",
    +         "tool_calls": [{"id": "fc_1", "call_id": "call_1",
    +                         "type": "function",
    +                         "function": {"name": "x", "arguments": "{}"}}]},
    +        {"role": "tool", "tool_call_id": "call_1", "content": "valid"},
    +        {"role": "tool", "tool_call_id": "call_ORPHAN", "content": "stray"},
    +        {"role": "user", "content": "next"},
    +    ]
    +
    +    repairs = AIAgent._repair_message_sequence(agent, messages)
    +
    +    assert repairs == 1
    +    tool_ids = [m["tool_call_id"] for m in messages if m.get("role") == "tool"]
    +    assert tool_ids == ["call_1"]
    +
    +
     def test_repair_leaves_valid_conversation_unchanged():
         agent = _bare_agent()
         messages = [
    ```
- **#97203: 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**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/agent_runtime_helpers.py`, `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `tests/run_agent/test_message_sequence_repair.py`
- **#24330: fix: strict tool-message adjacency check for Moonshot AI / Kimi**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - files: `agent/context_compressor.py`, `run_agent.py`
- **#24405: fix(agent): adjacency-aware tool-message orphan detection**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - files: `agent/context_compressor.py`, `run_agent.py`
- **#36157: fix(agent): enforce tool-result adjacency after compression**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`
- **#79883: fix(agent): rebuild sanitize_api_messages for immediate tool adjacency**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#24330`, `#24405`, `#24328`

## Fix status

- Merged to main in PR #97203 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-08-28`.
- Subsystem: `agent`
