hermes - ✅(Solved) Fix Truncated tool_call.arguments in conversation history wedges the retry loop [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#14443Fetched 2026-04-24 06:17:15
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
0
Timeline (top)
labeled ×3cross-referenced ×2commented ×1

If a streamed assistant response is cut off mid input_json_delta, the resulting tool_call.arguments is saved to conversation history as a truncated (non-JSON) string. Every subsequent API request re-sends that history, and Anthropic-compatible proxies (LiteLLM in particular) reject it with a 400 Failed to parse tool call arguments. The retry loop cannot recover because the bad message is deterministically pinned in history — all retries and fallback fail the same way.

Error Message

⚠️ API call failed (attempt 1/3): BadRequestError [HTTP 400] Error: HTTP 400: litellm.BadRequestError: AnthropicException - Failed to parse tool call arguments for tool 'patch' (Anthropic tool invoke). Error: Unterminated string starting at: line 1 column 76 (char 75). Arguments: {"path": "~/...file.py", "old_string": " # Anthropic think

Root Cause

The Anthropic adapter (agent/anthropic_adapter.py:1133) guards this path:

try:
    parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
    parsed_args = {}

But the Anthropic transport delegates to the adapter only after its own convert_messages, and the chat-completions transport (agent/transports/chat_completions.py) — which is what drives LiteLLM/OpenRouter/etc. — has no guard at all. It forwards tool_calls[*].function.arguments through as-is, and LiteLLM's internal OpenAI → Anthropic converter raises when it hits malformed JSON.

Fix Action

Fix / Workaround

⚠️  API call failed (attempt 1/3): BadRequestError [HTTP 400]
   Error: HTTP 400: litellm.BadRequestError: AnthropicException -
     Failed to parse tool call arguments for tool 'patch' (Anthropic tool invoke).
     Error: Unterminated string starting at: line 1 column 76 (char 75).
     Arguments: {"path": "~/...file.py", "old_string": "    # Anthropic think

Sanitize at the outbound boundary in both transports' convert_messages. Drop (do not repair) malformed tool_calls before they leave the process, plus strip orphan tool results whose tool_call_id no longer has a matching assistant tool_call. Dropping (rather than repairing to {}) is intentional — a {}-arg patch call would execute a garbage edit; dropping it lets the model re-attempt on the next turn.

PR fix notes

PR #14444: fix(transports): drop malformed tool_call.arguments before outbound request

Description (problem / solution / changelog)

Problem

A truncated streamed assistant response can save a tool_call with non-JSON function.arguments to conversation history. On the next turn, Anthropic-compatible proxies (LiteLLM in particular) reject the outbound request with 400 Failed to parse tool call arguments, and because the bad message is pinned in history, every retry and the direct-Anthropic fallback fail the same way. See #14443.

Fix

Add a pre-pass to ChatCompletionsTransport.convert_messages and AnthropicTransport.convert_messages that:

  1. Scans every assistant message's tool_calls and json.loads() each function.arguments string.
  2. Drops any entry that fails to parse. If all are dropped, the tool_calls key is removed entirely (strict providers reject tool_calls: []).
  3. Injects a placeholder content if the message would otherwise be empty.
  4. Strips orphan tool messages whose tool_call_id no longer has a surviving counterpart.
  5. Emits a single logger.warning per call if anything was dropped.
  6. Short-circuits with no mutation and no deepcopy when nothing is malformed.

The sanitization is symmetrical across both transports so the behavior is independent of which provider we're talking to. The existing anthropic_adapter.convert_messages_to_anthropic adapter-level guard is left untouched — this adds a transport-level safety net that protects providers we hit via the chat-completions path (LiteLLM, OpenRouter routing to Anthropic, etc.).

Design choice: drop vs. repair

We drop malformed tool_calls rather than repairing arguments to {}. A {}-arg tool call (e.g. a patch with no path/old_string) would execute a garbage action or surface a confusing downstream error. Dropping removes the bad call entirely; the model re-attempts on the next turn with whatever context it still has.

Tests

  • tests/agent/transports/test_chat_completions.py::TestMalformedToolCallSanitization — 5 cases
  • tests/agent/transports/test_anthropic.py::TestAnthropicMalformedToolCallSanitization — 6 cases (including warning log assertion)

Coverage: mixed valid/malformed, all-malformed with orphan tool result, all-clean identity (verifies no deepcopy), empty-string arguments treated as clean (not malformed), surviving-tool-result preservation, logger.warning emission.

Full suite: pytest tests/agent/transports/130 passed, 0 regressions.

Files changed

  • agent/transports/chat_completions.py — +113 lines (pre-pass + module logger)
  • agent/transports/anthropic.py — +114 lines (pre-pass + module logger)
  • tests/agent/transports/test_chat_completions.py — +163 lines (new test class)
  • tests/agent/transports/test_anthropic.py — new file, 244 lines

No changes to run_agent.py, error_classifier.py, or anthropic_adapter.py.

Non-goals

  • Not changing the retry loop in run_agent.py — once the bad message is cleaned out of the outbound request, the existing retry logic works.
  • Not adding a new FailoverReason for this class of error — prevention at the outbound boundary is simpler and covers more cases than post-hoc classification.

Changed files

  • agent/transports/anthropic.py (modified, +111/-3)
  • agent/transports/chat_completions.py (modified, +108/-5)
  • tests/agent/transports/test_anthropic.py (added, +244/-0)
  • tests/agent/transports/test_chat_completions.py (modified, +163/-0)

PR #14455: fix: sanitize malformed tool_calls and orphan tool results in outbound messages

Description (problem / solution / changelog)

Summary

When a streamed assistant response is cut off mid input_json_delta, the resulting tool_call.arguments is saved to conversation history as a truncated (non-JSON) string. Every subsequent API request re-sends that history, and Anthropic-compatible proxies (LiteLLM in particular) reject it with HTTP 400. The retry loop cannot recover because the bad message is deterministically pinned in history.

Fix

Add sanitize_tool_calls_in_messages() in transports/base.py and call it from both outbound paths:

  • ChatCompletionsTransport.convert_messages — covers OpenAI-compatible providers (LiteLLM, OpenRouter, etc.)
  • anthropic_adapter.convert_messages_to_anthropic — covers direct Anthropic path

The sanitizer:

  1. Drops assistant messages whose tool_calls contain malformed function.arguments (not valid JSON)
  2. Drops orphan tool results whose tool_call_id no longer has a matching assistant tool_call

Dropping (rather than repairing to {}) is intentional — a {}-arg tool call would execute a garbage edit; dropping lets the model re-attempt on the next turn.

Verification

  • tests/agent/transports/test_chat_completions.py — 5 new regression tests for malformed/valid/mixed tool_calls and orphan results
  • All existing chat_completions tests pass (39 tests)
  • All existing anthropic_adapter tests pass (137 tests)
  • Other transport tests pass (250 total, 11 codex failures are pre-existing missing openai dependency)

Fixes #14443

Changed files

  • agent/anthropic_adapter.py (modified, +4/-0)
  • agent/transports/base.py (modified, +110/-1)
  • agent/transports/chat_completions.py (modified, +19/-10)
  • tests/agent/transports/test_chat_completions.py (modified, +91/-0)

Code Example

⚠️  API call failed (attempt 1/3): BadRequestError [HTTP 400]
   Error: HTTP 400: litellm.BadRequestError: AnthropicException -
     Failed to parse tool call arguments for tool 'patch' (Anthropic tool invoke).
     Error: Unterminated string starting at: line 1 column 76 (char 75).
     Arguments: {"path": "~/...file.py", "old_string": "    # Anthropic think

---

try:
    parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
    parsed_args = {}
RAW_BUFFERClick to expand / collapse

Truncated tool_call.arguments in conversation history wedges the retry loop

Summary

If a streamed assistant response is cut off mid input_json_delta, the resulting tool_call.arguments is saved to conversation history as a truncated (non-JSON) string. Every subsequent API request re-sends that history, and Anthropic-compatible proxies (LiteLLM in particular) reject it with a 400 Failed to parse tool call arguments. The retry loop cannot recover because the bad message is deterministically pinned in history — all retries and fallback fail the same way.

Reproduction

  1. Be running through a LiteLLM proxy to an Anthropic-family model.
  2. Have the upstream connection drop (transient network, proxy timeout, etc.) while the model is mid input_json_delta for a tool call.
  3. Observe the truncated tool_call.arguments land in messages for the current session.
  4. Any next turn produces:
⚠️  API call failed (attempt 1/3): BadRequestError [HTTP 400]
   Error: HTTP 400: litellm.BadRequestError: AnthropicException -
     Failed to parse tool call arguments for tool 'patch' (Anthropic tool invoke).
     Error: Unterminated string starting at: line 1 column 76 (char 75).
     Arguments: {"path": "~/...file.py", "old_string": "    # Anthropic think

All 3 retries fail identically. Fallback to the direct Anthropic endpoint would fail the same way for the same reason (it's the outbound history that's malformed, not the provider).

Root cause

The Anthropic adapter (agent/anthropic_adapter.py:1133) guards this path:

try:
    parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
    parsed_args = {}

But the Anthropic transport delegates to the adapter only after its own convert_messages, and the chat-completions transport (agent/transports/chat_completions.py) — which is what drives LiteLLM/OpenRouter/etc. — has no guard at all. It forwards tool_calls[*].function.arguments through as-is, and LiteLLM's internal OpenAI → Anthropic converter raises when it hits malformed JSON.

Proposed fix

Sanitize at the outbound boundary in both transports' convert_messages. Drop (do not repair) malformed tool_calls before they leave the process, plus strip orphan tool results whose tool_call_id no longer has a matching assistant tool_call. Dropping (rather than repairing to {}) is intentional — a {}-arg patch call would execute a garbage edit; dropping it lets the model re-attempt on the next turn.

A PR implementing this is attached.

Why transport-level and not only adapter-level

The adapter-level guard only protects the direct-Anthropic path. Proxies that speak OpenAI chat-completions to us (LiteLLM, OpenRouter with Anthropic routing) never hit the adapter — they hit ChatCompletionsTransport and the malformed arguments pass straight through to a provider that will reject them. Putting the guard in the transport ensures coverage regardless of downstream provider shape.

Environment

  • Hermes Agent main branch (commit fa8f0c6f, v0.8.0+)
  • LiteLLM proxy → Anthropic (observed); direct Anthropic would hit the same path via transport-level convert
  • Python 3.11.15

extent analysis

TL;DR

Sanitize malformed tool_calls at the outbound boundary in both transports' convert_messages to prevent truncated tool_call.arguments from causing retry loops.

Guidance

  • Identify and drop malformed tool_calls before they leave the process to prevent them from being sent to the provider.
  • Strip orphan tool results whose tool_call_id no longer has a matching assistant tool_call to maintain data consistency.
  • Implement the proposed fix in the convert_messages method of both transports to ensure coverage regardless of downstream provider shape.
  • Verify that the fix works by testing the retry loop with a truncated tool_call.arguments and checking that it no longer fails with a 400 Failed to parse tool call arguments error.

Example

No code snippet is provided as the issue already includes a proposed fix and the exact implementation details are not necessary for this guidance.

Notes

The fix should be applied to both the ChatCompletionsTransport and the adapter to ensure that all paths are protected against malformed tool_calls. The proposed fix is specific to the Hermes Agent main branch (commit fa8f0c6f, v0.8.0+) and may not apply to other versions or environments.

Recommendation

Apply the proposed workaround by sanitizing malformed tool_calls at the outbound boundary in both transports' convert_messages, as this ensures coverage regardless of downstream provider shape and prevents truncated tool_call.arguments from causing retry loops.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix Truncated tool_call.arguments in conversation history wedges the retry loop [2 pull requests, 1 comments, 2 participants]