Tool call ID corruption causing persistent 400 errors

NousResearch/hermes-agent · cluster 16472 · agent

Data as of

Nothing open on GitHub. Fix merged in #21385. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #21385. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple fixes and bug reports addressing how stale or malformed tool_call_ids and tool messages cause repeated HTTP 400 error loops across sessions. Work spans error classification, context compression, and agent run logic.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #21385 fix(run_agent): break permanent empty-response loop from orphan tool-tail

    merged to main

    files: run_agent.py, tests/run_agent/test_empty_response_recovery_persistence.py, tests/run_agent/test_message_sequence_repair.py

    Show the diff
    • run_agent.py
      @@ -3836,7 +3836,17 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict
               self._flush_messages_to_session_db(messages, conversation_history)
       
           def _drop_trailing_empty_response_scaffolding(self, messages: List[Dict]) -> None:
      -        """Remove private empty-response retry/failure scaffolding from transcript tails."""
      +        """Remove private empty-response retry/failure scaffolding from transcript tails.
      +
      +        Also rewinds past any trailing tool-result / assistant(tool_calls) pair
      +        that the failed iteration left hanging. Without this, the tail ends at
      +        a raw ``tool`` message and the next user turn lands as
      +        ``...tool, user, user`` — a protocol-invalid sequence that most
      +        providers silently reject (returns empty content), causing the
      +        empty-retry loop to fire forever. See #<TBD>.
      +        """
      +        # Pass 1: strip the flagged scaffolding messages themselves.
      +        dropped_scaffolding = False
               while (
                   messages
                   and isinstance(messages[-1], dict)
      @@ -3846,6 +3856,137 @@ def _drop_trailing_empty_response_scaffolding(self, messages: List[Dict]) -> Non
                   )
               ):
                   messages.pop()
      +            dropped_scaffolding = True
      +
      +        # Pass 2: if we stripped scaffolding, rewind through any trailing
      +        # tool-result messages plus the assistant(tool_calls) message that
      +        # produced them. This preserves role alternation so the next user
      +        # message follows a user or assistant message, not an orphan tool
      +        # result. Only runs when scaffolding was actually present — normal
      +        # conversation tails (real tool loops mid-progress) are untouched.
      +        if not dropped_scaffolding:
      +            return
      +
      +        # Drop any trailing tool-result messages
      +        while (
      +            messages
      +            and isinstance(messages[-1], dict)
      +            and messages[-1].get("role") == "tool"
      +        ):
      +            messages.pop()
      +
      +        # Drop the assistant message that issued the tool calls, if the tail
      +        # now ends in an assistant-with-tool_calls (the pair that owned the
      +        # just-popped tool results). Without this, the tail is
      +        # ``assistant(tool_calls=...)`` with no tool answers, which some
      +        # providers also reject.
      +        if (
      +            messages
      +            and isinstance(messages[-1], dict)
      +            and messages[-1].get("role") == "assistant"
      +            and messages[-1].get("tool_calls")
      +        ):
      +            messages.pop()
      +
      +    def _repair_message_sequence(self, messages: List[Dict]) -> int:
      +        """Collapse malformed role-alternation left in the live history.
      +
      +        Providers (OpenAI, OpenRouter, Anthropic) expect strict alternation:
      +        after the system message, user/tool alternates with assistant, with
      +        no two consecutive user messages and no tool-result that doesn't
      +        follow an assistant-with-tool_calls. Violations cause silent empty
      +        responses on most providers, which triggers the empty-retry loop.
      +
      +        This runs right before the API call as a defensive belt — by the
      +        time it fires, the scaffolding strip should already have prevented
      +        most shapes, but external callers (gateway multi-queue replay,
      +        session resume, cron, explicit conversation_history passed in by
      +        host code) can feed in already-broken histories.
      +
      +        Repairs applied:
      +          1. Stray ``tool`` messages whose ``tool_call_id`` doesn't match
      +             any preceding assistant tool_call — dropped.
      +          2. Consecutive ``user`` messages — merged with newline separator
      +             so no user input is lost.
      +
      +        Deliberately does NOT rewind orphan ``assistant(tool_calls)+tool``
      +        pairs that precede a user message — that pattern IS valid when the
      +        previous turn completed normally and the user jumped in to redirect
      +        before the model got a continuation turn (the ongoing dialog
      +        pattern). The empty-response scaffolding stripper handles the
      +        genuinely-broken variant via its flag-gated rewind.
      +
      +        Returns the number of repairs made (for logging/telemetry).
      +        """
      +        if not messages:
      +            return 0
      +
      +        repairs = 0
      +
      +        # 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.
      +        known_tool_ids: set = set()
      +        filtered: List[Dict] = []
      +        for msg in messages:
      +            if not isinstance(msg, dict):
      +                filtered.append(msg)
      +                continue
      +            role = msg.get("role")
      +            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)
      +                filtered.append(msg)
      +            elif role == "tool":
      +                tc_id = msg.get("tool_call_id")
      +                if tc_id and tc_id in known_tool_ids:
      +                    filtered.append(msg)
      +                else:
      +                    repairs += 1
      +            else:
      +                if role == "user":
      +                    # A user turn closes the tool-result run; subsequent
      +                    # tool messages without a fresh assistant tool_call
      +                    # are orphans.
      +                    known_tool_ids = set()
      +                filtered.append(msg)
      +
      +        # Pass 2: merge consecutive user messages. Preserves all user input
      +        # so nothing the user typed is lost.
      +        merged: List[Dict] = []
      +        for msg in filtered:
      +            if (
      +                merged
      +                and isinstance(msg, dict)
      +                and msg.get("role") == "user"
      +                and isinstance(merged[-1], dict)
      +                and merged[-1].get("role") == "user"
      +            ):
      +                prev = merged[-1]
      +                prev_content = prev.get("content", "")
      +                new_content = msg.get("content", "")
      +                # Only merge plain-text content; leave multimodal (list)
      +                # content alone — collapsing image/audio blocks risks
      +                # mangling the attachment structure.
      +                if isinstance(prev_content, str) and isinstance(new_content, str):
      +                    prev["content"] = (
      +                        (prev_content + "\n\n" + new_content)
      +                        if prev_content and new_content
      +                        else (prev_content or new_content)
      +                    )
      +                    repairs += 1
      +                    continue
      +            merged.append(msg)
      +
      +        if repairs > 0:
      +            # Rewrite in place so downstream paths (persistence, return
      +            # value, session DB flush) see the repaired sequence.
      +            messages[:] = merged
      +
      +        return repairs
       
           def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None):
               """Persist any un-flushed messages to the SQLite session store.
      @@ -11133,6 +11274,21 @@ def run_conversation(
                           self.session_id or "-",
                       )
       
      +            # Defensive: repair malformed role-alternation before API call.
      +            # Catches cases where the history got wedged into a
      +            # ``tool → user`` or ``user → user`` tail (e.g. after empty-
      +            # response scaffolding was stripped and a new user message
      +            # landed after an orphan tool result). Most providers return
      +            # empty content on malformed sequences, which would otherwise
      +            # retrigger the empty-retry loop indefinitely.
      +            repaired_seq = self._repair_message_sequence(messages)
      +            if repaired_seq > 0:
      +                request_logger.info(
      +                    "Repaired %s message-alternation violations before request (session=%s)",
      +                    repaired_seq,
      +                    self.session_id or "-",
      +                )
      +
                   api_messages = []
                   for idx, msg in enumerate(messages):
                       api_msg = msg.copy()
    • tests/run_agent/test_empty_response_recovery_persistence.py
      @@ -21,9 +21,21 @@ def _agent_with_stubbed_persistence():
       
       
       def test_persist_session_strips_trailing_empty_recovery_scaffolding():
      +    """After stripping scaffolding, also rewind past orphan trailing tool-result
      +    messages that the failed iteration left behind. Otherwise the next user
      +    message lands after a bare ``tool`` and produces a protocol-invalid
      +    sequence that most providers silently fail on, retriggering the empty-
      +    retry loop indefinitely.
      +    """
           agent = _agent_with_stubbed_persistence()
           messages = [
               {"role": "user", "content": "run the task"},
      +        {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [{"id": "call_1", "type": "function",
      +                            "function": {"name": "x", "arguments": "{}"}}],
      +        },
               {"role": "tool", "content": "{}", "tool_call_id": "call_1"},
               {
                   "role": "assistant",
      @@ -42,9 +54,11 @@ def test_persist_session_strips_trailing_empty_recovery_scaffolding():
       
           AIAgent._persist_session(agent, messages, conversation_history=[])
       
      +    # After strip + rewind, only the original user message remains. The
      +    # assistant(tool_calls) + tool pair is dropped because its iteration
      +    # never produced a real response.
           assert messages == [
               {"role": "user", "content": "run the task"},
      -        {"role": "tool", "content": "{}", "tool_call_id": "call_1"},
           ]
           assert agent.saved_session_logs[-1] == messages
           assert all(not msg.get("_empty_recovery_synthetic") for msg in messages)
    • tests/run_agent/test_message_sequence_repair.py
      @@ -0,0 +1,201 @@
      +"""Tests for pre-API-call message-sequence repair.
      +
      +Covers ``_repair_message_sequence`` and the extended
      +``_drop_trailing_empty_response_scaffolding`` behavior that rewinds past
      +orphan tool-result tails. Together these prevent the self-reinforcing empty-
      +response loop observed in session 20260507_044111_fa7e65, where a tool-result
      +followed directly by a user message produced silent empty responses from
      +providers (violating role alternation), which retriggered the empty-retry
      +recovery every turn.
      +"""
      +
      +from run_agent import AIAgent
      +
      +
      +def _bare_agent():
      +    return AIAgent.__new__(AIAgent)
      +
      +
      +# ── _drop_trailing_empty_response_scaffolding ──────────────────────────────
      +
      +def test_drop_scaffolding_rewinds_orphan_tool_tail():
      +    """When scaffolding is stripped, also rewind the orphan assistant+tool pair."""
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "task"},
      +        {"role": "assistant", "content": "",
      +         "tool_calls": [{"id": "t1", "type": "function",
      +                         "function": {"name": "f", "arguments": "{}"}}]},
      +        {"role": "tool", "tool_call_id": "t1", "content": "out"},
      +        {"role": "assistant", "content": "(empty)",
      +         "_empty_terminal_sentinel": True},
      +    ]
      +
      +    AIAgent._drop_trailing_empty_response_scaffolding(agent, messages)
      +
      +    assert messages == [{"role": "user", "content": "task"}]
      +
      +
      +def test_drop_scaffolding_keeps_tail_when_no_scaffolding():
      +    """Mid-iteration tool results must NOT be rewound — only if scaffolding fires."""
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "task"},
      +        {"role": "assistant", "content": "",
      +         "tool_calls": [{"id": "t1", "type": "function",
      +                         "function": {"name": "f", "arguments": "{}"}}]},
      +        {"role": "tool", "tool_call_id": "t1", "content": "out"},
      +    ]
      +    original = [dict(m) for m in messages]
      +
      +    AIAgent._drop_trailing_empty_response_scaffolding(agent, messages)
      +
      +    assert messages == original
      +
      +
      +def test_drop_scaffolding_handles_multiple_parallel_tool_results():
      +    """Parallel tool calls (one assistant → many tool results) all rewound together."""
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "task"},
      +        {"role": "assistant", "content": "",
      +         "tool_calls": [
      +             {"id": "t1", "type": "function",
      +              "function": {"name": "f", "arguments": "{}"}},
      +             {"id": "t2", "type": "function",
      +              "function": {"name": "g", "arguments": "{}"}},
      +         ]},
      +        {"role": "tool", "tool_call_id": "t1", "content": "out1"},
      +        {"role": "tool", "tool_call_id": "t2", "content": "out2"},
      +        {"role": "assistant", "content": "(empty)",
      +         "_empty_terminal_sentinel": True},
      +    ]
      +
      +    AIAgent._drop_trailing_empty_response_scaffolding(agent, messages)
      +
      +    assert messages == [{"role": "user", "content": "task"}]
      +
      +
      +# ── _repair_message_sequence ───────────────────────────────────────────────
      +
      +def test_repair_merges_consecutive_user_messages():
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "first"},
      +        {"role": "user", "content": "second"},
      +    ]
      +
      +    repairs = AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert repairs == 1
      +    assert len(messages) == 1
      +    assert messages[0]["role"] == "user"
      +    assert messages[0]["content"] == "first\n\nsecond"
      +
      +
      +def test_repair_preserves_user_content_when_one_side_empty():
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": ""},
      +        {"role": "user", "content": "real message"},
      +    ]
      +
      +    AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert messages == [{"role": "user", "content": "real message"}]
      +
      +
      +def test_repair_does_not_rewind_ongoing_dialog_tool_pair():
      +    """assistant(tool_calls) + tool + user is a VALID pattern (user redirect
      +    before the model gets its continuation turn). Repair must not touch it —
      +    only the flag-gated scaffolding strip rewinds, and only when the
      +    empty-recovery scaffolding was actually present.
      +    """
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "Q1"},
      +        {"role": "assistant", "content": "",
      +         "tool_calls": [{"id": "t1", "type": "function",
      +                         "function": {"name": "f", "arguments": "{}"}}]},
      +        {"role": "tool", "tool_call_id": "t1", "content": "out"},
      +        {"role": "user", "content": "Q2"},
      +    ]
      +    original = [dict(m) for m in messages]
      +
      +    repairs = AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert repairs == 0
      +    assert messages == original
      +
      +
      +def test_repair_drops_stray_tool_with_unknown_tool_call_id():
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "hi"},
      +        {"role": "assistant", "content": "hello"},
      +        {"role": "tool", "tool_call_id": "orphan", "content": "stray"},
      +        {"role": "user", "content": "real"},
      +    ]
      +
      +    repairs = AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert repairs >= 1
      +    assert all(m.get("role") != "tool" for m in messages)
      +
      +
      +def test_repair_leaves_valid_conversation_unchanged():
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": "list files"},
      +        {"role": "assistant", "content": "",
      +         "tool_calls": [{"id": "t1", "type": "function",
      +                         "function": {"name": "ls", "arguments": "{}"}}]},
      +        {"role": "tool", "tool_call_id": "t1", "content": "a.txt b.txt"},
      +        {"role": "assistant", "content": "Found 2 files"},
      +        {"role": "user", "content": "more"},
      +    ]
      +    original = [dict(m) for m in messages]
      +
      +    repairs = AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert repairs == 0
      +    assert messages == original
      +
      +
      +def test_repair_preserves_multimodal_user_content():
      +    """Multimodal (list) content must NOT be merged — risks mangling attachments."""
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "user", "content": [{"type": "text", "text": "hi"},
      +                                     {"type": "image_url", "image_url": {"url": "..."}}]},
      +        {"role": "user", "content": "follow-up"},
      +    ]
      +
      +    AIAgent._repair_message_sequence(agent, messages)
      +
      +    # The multimodal user message stays as a distinct message — no merge
      +    assert len(messages) == 2
      +    assert isinstance(messages[0]["content"], list)
      +
      +
      +def test_repair_empty_messages_returns_zero():
      +    agent = _bare_agent()
      +    messages = []
      +
      +    repairs = AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert repairs == 0
      +    assert messages == []
      +
      +
      +def test_repair_preserves_system_messages():
      +    agent = _bare_agent()
      +    messages = [
      +        {"role": "system", "content": "You are..."},
      +        {"role": "user", "content": "hi"},
      +    ]
      +    original = [dict(m) for m in messages]
      +
      +    AIAgent._repair_message_sequence(agent, messages)
      +
      +    assert messages == original

3 PRs closed without merging

From the close comments: done elsewhere 1 · superseded by another PR 1 · other 1

  • #16602 fix(agent): invalidate failed tool_call_id on MiniMax 400 errors · superseded by another PR → #16604
  • #16604 fix(error_classifier): purge stale tool_call_id on 400 to prevent cross-session reuse (#16472) · other → #16472
  • #18650 fix(compression): sanitize malformed tool messages + auto-recover on API 400 · done elsewhere → 812ce0b, #21385
Closed issues · 2
  • Closed#16472Bug: Tool call ID not invalidated after 400 error — causes persistent failures across sessions
  • Closed#27033[Bug]: Tool Result Contamination Causes Persistent HTTP 400 Error Loop
How this cluster was grouped
  • Link evidence (partly structural links): 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.29: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#21385
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
7 May 2026

For your agent

The same facts, typed.