# NousResearch/hermes-agent · Malformed tool-call arguments sanitization and repair

## Open now

- 2 issues, 0 PRs open on GitHub.
- #13480: \[Bug\]: JSON Parsing Error in Tool Call Arguments
- #58057: \[Bug\]: sanitize_tool_call_arguments does not validate JSON type — array arguments cause HTTP 400 on strict providers

These issues and fixes center on detecting, repairing, and preventing malformed tool-call argument JSON—including truncated strings from session compression, wrong JSON types (arrays instead of objects), and unescaped control characters—before they are persisted or sent to model providers.

## TL;DR

- 36 member(s): 2 open issue(s), 9 closed issue(s), 0 open PR(s), 5 merged PR(s), 20 PR(s) closed without merging.
- A fix was merged to main in PR #12259. Verify it addresses your case.
- **Recommended:** read PR #12259 (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-12259,pr-15348`. 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.05`: 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.

## Open related issues

- #13480: \[Bug\]: JSON Parsing Error in Tool Call Arguments
- #58057: \[Bug\]: sanitize_tool_call_arguments does not validate JSON type — array arguments cause HTTP 400 on strict providers

## Closed / duplicate issues

- #4662: \[Bug\]: Malformed persisted tool calls can poison a session and cause repeated 400 errors on subsequent requests
- #10398: \[Bug\]: 飞书发送消息回复错误
- #12068: \[Bug\]: Memory save tool call fails JSON parse on llama.cpp — unescaped apostrophes in summary string
- #12167: \[MiniMax\] Tool call parameter error - HTTP 400 with code 2013
- #12242: \[Bug\] StepFun API: Unterminated string error caused by broken JSON in context_compressor
- #12731: Session compression corrupts tool_call arguments, truncating JSON to 214 chars
- #13574: \[BUG\] Context compaction truncates tool_call.function.arguments mid-string, produces invalid JSON, causes 400 errors with strict providers (e.g. MiniMax)
- #14443: Truncated tool_call.arguments in conversation history wedges the retry loop
- #15236: Compressed sessions with corrupted tool_calls.arguments JSON brick chats with HTTP 400 (invalid_tool_call_format)

## Prior & competing PRs

- **#4724: fix: validate tool calls before persisting to prevent session poisoning**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#14407`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#4838: fix(agent): sanitize malformed tool calls before persist and replay**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12259: fix(context_compressor): keep tool-call arguments JSON valid when shrinking**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/context_compressor.py`, `scripts/release.py`, `tests/agent/test_context_compressor.py`
  - fix hunks:
  - `agent/context_compressor.py`

    ```diff
    @@ -63,6 +63,52 @@
     _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
     
     
    +def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
    +    """Shrink long string values inside a tool-call arguments JSON blob while
    +    preserving JSON validity.
    +
    +    The ``function.arguments`` field on a tool call is a JSON-encoded string
    +    passed through to the LLM provider; downstream providers strictly
    +    validate it and return a non-retryable 400 when it is not well-formed.
    +    An earlier implementation sliced the raw JSON at a fixed byte offset and
    +    appended ``...[truncated]`` — which routinely produced strings like::
    +
    +        {"path": "/foo/bar", "content": "# long markdown
    +        ...[truncated]
    +
    +    i.e. an unterminated string and a missing closing brace. MiniMax, for
    +    example, rejects this with ``invalid function arguments json string``
    +    and the session gets stuck re-sending the same broken history on every
    +    turn. See issue #11762 for the observed loop.
    +
    +    This helper parses the arguments, shrinks long string leaves inside the
    +    parsed structure, and re-serialises. Non-string values (paths, ints,
    +    booleans) are preserved intact. If the arguments are not valid JSON
    +    to begin with — some model backends use non-JSON tool arguments — the
    +    original string is returned unchanged rather than replaced with
    +    something neither we nor the backend can parse.
    +    """
    +    try:
    +        parsed = json.loads(args)
    +    except (ValueError, TypeError):
    +        return args
    +
    +    def _shrink(obj: Any) -> Any:
    +        if isinstance(obj, str):
    +            if len(obj) > head_chars:
    +                return obj[:head_chars] + "...[truncated]"
    +            return obj
    +        if isinstance(obj, dict):
    +            return {k: _shrink(v) for k, v in obj.items()}
    +        if isinstance(obj, list):
    +            return [_shrink(v) for v in obj]
    +        return obj
    +
    +    shrunken = _shrink(parsed)
    +    # ensure_ascii=False preserves CJK/emoji instead of bloating with \uXXXX
    +    return json.dumps(shrunken, ensure_ascii=False)
    +
    +
     def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
         """Create an informative 1-line summary of a tool call + result.
     
    ```

    ```diff
    @@ -449,6 +495,11 @@ def _prune_old_tool_results(
             # Pass 3: Truncate large tool_call arguments in assistant messages
             # outside the protected tail. write_file with 50KB content, for
             # example, survives pruning entirely without this.
    +        #
    +        # The shrinking is done inside the parsed JSON structure so the
    +        # result remains valid JSON — otherwise downstream providers 400
    +        # on every subsequent turn until the broken call falls out of
    +        # the window. See ``_truncate_tool_call_args_json`` docstring.
             for i in range(prune_boundary):
                 msg = result[i]
                 if msg.get("role") != "assistant" or not msg.get("tool_calls"):
    ```

    ```diff
    @@ -459,8 +510,10 @@ def _prune_old_tool_results(
                     if isinstance(tc, dict):
                         args = tc.get("function", {}).get("arguments", "")
                         if len(args) > 500:
    -                        tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}}
    -                        modified = True
    +                        new_args = _truncate_tool_call_args_json(args)
    +                        if new_args != args:
    +                            tc = {**tc, "function": {**tc["function"], "arguments": new_args}}
    +                            modified = True
                     new_tcs.append(tc)
                 if modified:
                     result[i] = {**msg, "tool_calls": new_tcs}
    ```
  - `scripts/release.py`

    ```diff
    @@ -74,6 +74,7 @@
         "[redacted:email]": "davetist",
         "[redacted:email]": "yyq4193",
         "[redacted:email]": "Asunfly",
    +    "[redacted:email]": "honghua",
         # contributors (manual mapping from git names)
         "[redacted:email]": "asheriif",
         "[redacted:email]": "liujinkun2025",
    ```
  - `tests/agent/test_context_compressor.py`

    ```diff
    @@ -781,3 +781,127 @@ def test_prune_without_token_budget_uses_message_count(self, budget_compressor):
             # Tool at index 2 is outside the protected tail (last 3 = indices 2,3,4)
             # so it might or might not be pruned depending on boundary
             assert isinstance(pruned, int)
    +
    +
    +class TestTruncateToolCallArgsJson:
    +    """Regression tests for #11762.
    +
    +    The previous implementation produced invalid JSON by slicing
    +    ``function.arguments`` mid-string, which caused non-retryable 400s from
    +    strict providers (observed on MiniMax) and stuck long sessions in a
    +    re-send loop. The helper here must always emit parseable JSON whose
    +    shape matches the original — shrunken, not corrupted.
    +    """
    +
    +    def _helper(self):
    +        from agent.context_compressor import _truncate_tool_call_args_json
    +        return _truncate_tool_call_args_json
    +
    +    def test_shrunken_args_remain_valid_json(self):
    +        import json as _json
    +        shrink = self._helper()
    +        original = _json.dumps({
    +            "path": "~/.hermes/skills/shopping/browser-setup-notes.md",
    +            "content": "# Shopping Browser Setup Notes\n\n" + "abc " * 400,
    +        })
    +        assert len(original) > 500
    +        shrunk = shrink(original)
    +        parsed = _json.loads(shrunk)  # must not raise
    +        assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
    +        assert parsed["content"].endswith("...[truncated]")
    +        assert len(shrunk) < len(original)
    +
    +    def test_non_json_arguments_pass_through(self):
    +        shrink = self._helper()
    +        not_json = "this is not json at all, " * 50
    +        assert shrink(not_json) == not_json
    +
    +    def test_short_string_leaves_unchanged(self):
    +        import json as _json
    +        shrink = self._helper()
    +        payload = _json.dumps({"command": "ls -la", "cwd": "/tmp"})
    +        assert _json.loads(shrink(payload)) == {"command": "ls -la", "cwd": "/tmp"}
    +
    +    def test_nested_structures_are_walked(self):
    +        import json as _json
    +        shrink = self._helper()
    +        payload = _json.dumps({
    +            "messages": [
    +                {"role": "user", "content": "x" * 500},
    +                {"role": "assistant", "content": "ok"},
    +            ],
    +            "meta": {"note": "y" * 500},
    +        })
    +        parsed = _json.loads(shrink(payload))
    +        assert parsed["messages"][0]["content"].endswith("...[truncated]")
    +        assert parsed["messages"][1]["content"] == "ok"
    +        assert parsed["meta"]["note"].endswith("...[truncated]")
    +
    +    def test_non_string_leaves_preserved(self):
    +        import json as _json
    +        shrink = self._helper()
    +        payload = _json.dumps({
    +            "retries": 3,
    +            "enabled": True,
    +            "timeout": None,
    +            "items": [1, 2, 3],
    +            "note": "z" * 500,
    +        })
    +        parsed = _json.loads(shrink(payload))
    +        assert parsed["retries"] == 3
    +        assert parsed["enabled"] is True
    +        assert parsed["timeout"] is None
    +        assert parsed["items"] == [1, 2, 3]
    +        assert parsed["note"].endswith("...[truncated]")
    +
    +    def test_scalar_json_string_gets_shrunk(self):
    +        import json as _json
    +        shrink = self._helper()
    +        payload = _json.dumps("q" * 500)
    +        parsed = _json.loads(shrink(payload))
    +        assert isinstance(parsed, str)
    +        assert parsed.endswith("...[truncated]")
    +
    +    def test_unicode_preserved(self):
    +        import json as _json
    +        shrink = self._helper()
    +        payload = _json.dumps({"content": "非德满" + ("a" * 500)})
    +        out = shrink(payload)
    +        # ensure_ascii=False keeps CJK intact rather than emitting \uXXXX
    +        assert "非德满" in out
    +
    +    def test_pass3_emits_valid_json_for_downstream_provider(self):
    +        """End-to-end: Pass 3 must never produce the exact failure payload
    +        that caused the 400 loop (unterminated string, missing brace)."""
    +        import json as _json
    +        with patch("agent.context_compressor.get_model_context_length", return_value=100000):
    +            c = ContextCompressor(
    +                model="test/model",
    +                threshold_percent=0.85,
    +                protect_first_n=1,
    +                protect_last_n=1,
    +                quiet_mode=True,
    +            )
    +        huge_content = "# Shopping Browser Setup Notes\n\n## Overview\n" + "x " * 400
    +        args_payload = _json.dumps({
    +            "path": "~/.hermes/skills/shopping/browser-setup-notes.md",
    +            "content": huge_content,
    +        })
    +        assert len(args_payload) > 500  # triggers the Pass-3 shrink
    +        messages = [
    +            {"role": "user", "content": "please write two files"},
    +            {"role": "assistant", "content": None, "tool_calls": [
    +                {"id": "call_1", "type": "function",
    +                 "function": {"name": "write_file", "arguments": args_payload}},
    +            ]},
    +            {"role": "tool", "tool_call_id": "call_1",
    +             "content": '{"bytes_written": 727}'},
    +            {"role": "user", "content": "ok"},
    +            {"role": "assistant", "content": "done"},
    +        ]
    +        result, _ = c._prune_old_tool_results(messages, protect_tail_count=2)
    +        shrunk = result[1]["tool_calls"][0]["function"]["arguments"]
    +        # Must parse — otherwise downstream provider returns 400
    +        parsed = _json.loads(shrunk)
    +        assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
    +        assert parsed["content"].endswith("...[truncated]")
    ```
- **#12437: fix(context_compressor): properly truncate tool_calls to preserve valid JSON**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#13005: fix(agent): repair malformed tool_call arguments before API send**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `scripts/release.py`, `tests/run_agent/test_repair_tool_call_arguments.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -555,6 +555,71 @@ def _sanitize_messages_surrogates(messages: list) -> bool:
         return found
     
     
    +def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
    +    """Attempt to repair malformed tool_call argument JSON.
    +
    +    Models like GLM-5.1 via Ollama can produce truncated JSON, trailing
    +    commas, Python ``None``, etc.  The API proxy rejects these with HTTP 400
    +    "invalid tool call arguments".  This function applies common repairs;
    +    if all fail it returns ``"{}"`` so the request succeeds (better than
    +    crashing the session).  All repairs are logged at WARNING level.
    +    """
    +    raw_stripped = raw_args.strip() if isinstance(raw_args, str) else ""
    +
    +    # Fast-path: empty / whitespace-only -> empty object
    +    if not raw_stripped:
    +        logger.warning("Sanitized empty tool_call arguments for %s", tool_name)
    +        return "{}"
    +
    +    # Python-literal None -> normalise to {}
    +    if raw_stripped == "None":
    +        logger.warning("Sanitized Python-None tool_call arguments for %s", tool_name)
    +        return "{}"
    +
    +    # Attempt common JSON repairs
    +    fixed = raw_stripped
    +    # 1. Strip trailing commas before } or ]
    +    fixed = re.sub(r',\s*([}\]])', r'\1', fixed)
    +    # 2. Close unclosed structures
    +    open_curly = fixed.count('{') - fixed.count('}')
    +    open_bracket = fixed.count('[') - fixed.count(']')
    +    if open_curly > 0:
    +        fixed += '}' * open_curly
    +    if open_bracket > 0:
    +        fixed += ']' * open_bracket
    +    # 3. Remove excess closing braces/brackets (bounded to 50 iterations)
    +    for _ in range(50):
    +        try:
    +            json.loads(fixed)
    +            break
    +        except json.JSONDecodeError:
    +            if fixed.endswith('}') and fixed.count('}') > fixed.count('{'):
    +                fixed = fixed[:-1]
    +            elif fixed.endswith(']') and fixed.count(']') > fixed.count('['):
    +                fixed = fixed[:-1]
    +            else:
    +                break
    +
    +    try:
    +        json.loads(fixed)
    +        logger.warning(
    +            "Repaired malformed tool_call arguments for %s: %s → %s",
    +            tool_name, raw_stripped[:80], fixed[:80],
    +        )
    +        return fixed
    +    except json.JSONDecodeError:
    +        pass
    +
    +    # Last resort: replace with empty object so the API request doesn't
    +    # crash the entire session.
    +    logger.warning(
    +        "Unrepairable tool_call arguments for %s — "
    +        "replaced with empty object (was: %s)",
    +        tool_name, raw_stripped[:80],
    +    )
    +    return "{}"
    +
    +
     def _strip_non_ascii(text: str) -> str:
         """Remove non-ASCII characters, replacing with closest ASCII equivalent or removing.
     
    ```

    ```diff
    @@ -9662,7 +9727,10 @@ def run_conversation(
                                     ),
                                 }}
                             except Exception:
    -                            pass
    +                            tc["function"]["arguments"] = _repair_tool_call_arguments(
    +                                tc["function"]["arguments"],
    +                                tc["function"].get("name", "?"),
    +                            )
                         new_tcs.append(tc)
                     am["tool_calls"] = new_tcs
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -66,6 +66,8 @@
         "[redacted:email]": "Sertug17",
         "[redacted:email]": "caentzminger",
         "[redacted:email]": "voidborne-d",
    +    "[redacted:email]": "sirEven",
    +    "[redacted:email]": "sirEven",
         "[redacted:email]": "insecurejezza",
         "[redacted:email]": "dodo-reach",
         "[redacted:email]": "Bartok9",
    ```
  - `tests/run_agent/test_repair_tool_call_arguments.py`

    ```diff
    @@ -0,0 +1,107 @@
    +"""Tests for _repair_tool_call_arguments — malformed JSON repair pipeline."""
    +
    +import json
    +import pytest
    +
    +from run_agent import _repair_tool_call_arguments
    +
    +
    +class TestRepairToolCallArguments:
    +    """Verify each repair stage in the pipeline."""
    +
    +    # -- Stage 1: empty / whitespace-only --
    +
    +    def test_empty_string_returns_empty_object(self):
    +        assert _repair_tool_call_arguments("", "t") == "{}"
    +
    +    def test_whitespace_only_returns_empty_object(self):
    +        assert _repair_tool_call_arguments("   \n\t  ", "t") == "{}"
    +
    +    def test_none_type_returns_empty_object(self):
    +        """Non-string input (e.g. None from a broken model response)."""
    +        assert _repair_tool_call_arguments(None, "t") == "{}"
    +
    +    # -- Stage 2: Python None literal --
    +
    +    def test_python_none_literal(self):
    +        assert _repair_tool_call_arguments("None", "t") == "{}"
    +
    +    def test_python_none_with_whitespace(self):
    +        assert _repair_tool_call_arguments("  None  ", "t") == "{}"
    +
    +    # -- Stage 3: trailing comma repair --
    +
    +    def test_trailing_comma_in_object(self):
    +        result = _repair_tool_call_arguments('{"key": "value",}', "t")
    +        assert json.loads(result) == {"key": "value"}
    +
    +    def test_trailing_comma_in_array(self):
    +        result = _repair_tool_call_arguments('{"a": [1, 2,]}', "t")
    +        parsed = json.loads(result)
    +        assert parsed == {"a": [1, 2]}
    +
    +    def test_multiple_trailing_commas(self):
    +        result = _repair_tool_call_arguments('{"a": 1, "b": 2,}', "t")
    +        parsed = json.loads(result)
    +        assert parsed["a"] == 1
    +        assert parsed["b"] == 2
    +
    +    # -- Stage 4: unclosed brackets --
    +
    +    def test_unclosed_brace(self):
    +        result = _repair_tool_call_arguments('{"key": "value"', "t")
    +        parsed = json.loads(result)
    +        assert parsed == {"key": "value"}
    +
    +    def test_unclosed_bracket_and_brace(self):
    +        result = _repair_tool_call_arguments('{"a": [1, 2', "t")
    +        # Bracket counting adds ']' then '}', producing {"a": [1, 2]}
    +        # which is valid JSON.  But the naive count can't always recover
    +        # complex nesting — verify we at least get valid JSON.
    +        json.loads(result)
    +
    +    # -- Stage 5: excess closing delimiters --
    +
    +    def test_extra_closing_brace(self):
    +        result = _repair_tool_call_arguments('{"key": "value"}}', "t")
    +        parsed = json.loads(result)
    +        assert parsed == {"key": "value"}
    +
    +    def test_extra_closing_bracket(self):
    +        result = _repair_tool_call_arguments('{"a": [1]]}', "t")
    +        # Should produce valid JSON
    +        json.loads(result)
    +
    +    # -- Stage 6: last resort --
    +
    +    def test_unrepairable_garbage_returns_empty_object(self):
    +        assert _repair_tool_call_arguments("totally not json", "t") == "{}"
    +
    +    def test_unrepairable_partial_returns_empty_object(self):
    +        # Truncated in the middle of a string key — bracket closing won't help
    +        assert _repair_tool_call_arguments('{"truncated": "val', "t") == "{}"
    +
    +    # -- Valid JSON passthrough (this path is via except, but still works) --
    +
    +    def test_already_valid_json_passes_through(self):
    +        """When json.loads fails for a non-JSON reason (shouldn't normally
    +        happen), but the repair pipeline still produces valid output."""
    +        raw = '{"path": "/tmp/foo", "content": "hello"}'
    +        result = _repair_tool_call_arguments(raw, "t")
    +        parsed = json.loads(result)
    +        assert parsed["path"] == "/tmp/foo"
    +
    +    # -- Combined repairs --
    +
    +    def test_trailing_comma_plus_unclosed_brace(self):
    +        result = _repair_tool_call_arguments('{"a": 1, "b": 2,', "t")
    +        # Trailing comma stripped first, then closing brace added.
    +        # May or may not fully recover — verify valid JSON at minimum.
    +        json.loads(result)
    +
    +    def test_real_world_glm_truncation(self):
    +        """Simulates GLM-5.1 truncating mid-argument."""
    +        raw = '{"command": "ls -la /tmp", "timeout": 30, "background":'
    +        result = _repair_tool_call_arguments(raw, "terminal")
    +        # Should at least be valid JSON, even if background is lost
    +        json.loads(result)
    ```
- **#14455: fix: sanitize malformed tool_calls and orphan tool results in outbound messages**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#14444`, `#14443`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15348: fix(run_agent): persist tool_call argument repairs into session history**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `tests/run_agent/test_tool_call_args_sanitizer.py`
- **#15352: fix(run_agent): repair malformed tool_call args during streaming assembly**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `scripts/release.py`, `tests/run_agent/test_streaming_tool_call_repair.py`
- **#55922: fix(agent): repair empty-name tool_calls in sanitizer to prevent Responses 400 (salvage #12807/#52893)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/agent_runtime_helpers.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `agent/agent_runtime_helpers.py`

    ```diff
    @@ -2257,6 +2257,54 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
             filtered.append(msg)
         messages = filtered
     
    +    # --- Repair tool_calls whose function.name is empty/missing ---
    +    # Some providers (and partially-streamed responses) emit a tool_call with
    +    # id="call_xxx" but function.name="". Downstream Responses-API adapters
    +    # silently DROP such function_call items while still emitting the matching
    +    # function_call_output, producing the gateway's HTTP 400
    +    # "No tool call found for function call output with call_id ...".
    +    #
    +    # We do NOT drop the call: hermes' own dispatch loop intentionally keeps an
    +    # empty-name call paired with a synthesized anti-priming tool result
    +    # ("tool name was empty", see #47967) so weak models self-correct instead of
    +    # being fed the full tool catalog. Dropping the call here would (a) orphan
    +    # that result and strip the anti-priming signal, and (b) still leave any
    +    # provider-side orphan. Instead, rename the blank name to a non-empty
    +    # sentinel so the call and its result stay PAIRED — the adapter no longer
    +    # drops the function_call, so there is no orphaned output and no 400, while
    +    # the result content the model needs is preserved.
    +    _EMPTY_NAME_SENTINEL = "invalid_tool_call"
    +    for msg in messages:
    +        if msg.get("role") != "assistant":
    +            continue
    +        tcs = msg.get("tool_calls") or []
    +        if not tcs:
    +            continue
    +        for tc in tcs:
    +            if isinstance(tc, dict):
    +                fn = tc.get("function")
    +                name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None)
    +            else:
    +                fn = getattr(tc, "function", None)
    +                name = getattr(fn, "name", None) if fn else None
    +            if isinstance(name, str) and name.strip():
    +                continue
    +            _ra().logger.warning(
    +                "Pre-call sanitizer: repairing tool_call with empty "
    +                "function.name -> %r (id=%s)",
    +                _EMPTY_NAME_SENTINEL,
    +                _ra().AIAgent._get_tool_call_id_static(tc),
    +            )
    +            if isinstance(fn, dict):
    +                fn["name"] = _EMPTY_NAME_SENTINEL
    +            elif fn is not None and hasattr(fn, "name"):
    +                try:
    +                    fn.name = _EMPTY_NAME_SENTINEL
    +                except Exception:
    +                    pass
    +            elif isinstance(tc, dict):
    +                tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"}
    +
         surviving_call_ids: set = set()
         for msg in messages:
             if msg.get("role") == "assistant":
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -3659,6 +3659,48 @@ def test_api_sanitizer_matches_responses_call_id_when_id_differs(self, agent):
                 "call_123"
             ]
     
    +    def test_api_sanitizer_repairs_tool_call_with_empty_function_name(self, agent):
    +        """A tool_call with id but empty function.name makes the Responses-API
    +        adapter drop the function_call while keeping its function_call_output,
    +        causing the gateway's HTTP 400 'No tool call found for function call
    +        output ...'. The sanitizer renames the blank name to a non-empty
    +        sentinel so the call and its result stay PAIRED (no orphaned output,
    +        no 400) while the result content is preserved — it must NOT drop the
    +        call, because hermes' dispatch loop keeps empty-name calls paired with
    +        an anti-priming result for self-correction (#47967). (#12807)"""
    +        messages = [
    +            {
    +                "role": "assistant",
    +                "content": "",
    +                "tool_calls": [
    +                    {
    +                        "id": "call_good",
    +                        "type": "function",
    +                        "function": {"name": "web_search", "arguments": "{}"},
    +                    },
    +                    {
    +                        "id": "call_bad",
    +                        "type": "function",
    +                        "function": {"name": "", "arguments": "{}"},
    +                    },
    +                ],
    +            },
    +            {"role": "tool", "tool_call_id": "call_good", "content": "ok"},
    +            {"role": "tool", "tool_call_id": "call_bad", "content": "orphan"},
    +        ]
    +
    +        sanitized = agent._sanitize_api_messages(messages)
    +
    +        # The good call is untouched; the malformed call is repaired in place
    +        # (renamed to a non-empty sentinel) rather than dropped.
    +        assistant = next(m for m in sanitized if m.get("role") == "assistant")
    +        names = [tc["function"]["name"] for tc in assistant["tool_calls"]]
    +        assert names == ["web_search", "invalid_tool_call"]
    +        # Both calls now have non-empty names, so neither output is orphaned
    +        # and both tool results survive — this is what prevents the 400.
    +        tool_ids = [m.get("tool_call_id") for m in sanitized if m.get("role") == "tool"]
    +        assert tool_ids == ["call_good", "call_bad"]
    +
     
     class TestRunConversation:
         """Tests for the main run_conversation method.
    ```
- **#58065: fix(agent): require object tool-call arguments**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `0d8b5f4a4a72`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#58076: fix(agent): sanitize tool_call arguments that are JSON arrays, not ob…**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#58168`, `#58065`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#58092: fix: validate JSON type in sanitize_tool_call_arguments — reject non-dict arguments**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#58065`, `#58057`, `#58065`, `#58076`, `#58065`, `#58076`, `#58057`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#5071: fix(agent): filter malformed tool calls before persistence and replay**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#12259`, `#15348`, `#4662`
- **#12093: fix(memory): handle unescaped apostrophes in tool call JSON from llama.cpp**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `2d444fc84`
- **#13057: fix(session): validate and repair tool_call arguments before persisting**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#14383`, `#14383`
- **#14383: fix(session): validate and repair tool_call arguments on both shapes (supersedes #13057)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#13057`, `#12731`, `#15352`, `#13005`, `#12731`
- **#15241: fix(run_agent): repair corrupted tool_call arguments before sending to provider**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `7a192b124`, `#15348`
- **#18650: fix(compression): sanitize malformed tool messages + auto-recover on API 400**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `812ce0b9878d1dc9ac1f7c419a620deeb57117f3`, `#21385`
- **#54856: fix(agent): heal empty-name tool calls — drop on persist, heal on session load (salvage #5071)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `0cebf994c965672090a58a0dec3b78f565fbaac5`, `#55922`, `#55922`, `#47967`, `#12807`
- **#58124: fix: sanitize_tool_call_arguments now validates JSON type and unwraps single-element arrays**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#58164: fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#58065`, `#58057`, `#58065`, `#58076`, `#58092`, `#58124`, `#58057`, `#58065`
- **#66885: fix(agent): validate tool_call.arguments is a JSON object (not array/scalar) — #58057**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#58057`, `#58065`
- **#69100: fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#58057`
- **#74477: fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#58065`, `#58065`
- **#80594: fix(agent): validate tool_call argument type and unwrap single-element arrays (#58057)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

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