Malformed tool-call arguments sanitization and repair

NousResearch/hermes-agent · cluster 4662 · agent

Data as of

2 open items. Fix merged in #12259. Check it covers your case.

The short answer

2open issues
0open PRs
36items grouped
Known?
Yes. 36 related items grouped here, 9 issues already closed.
Fix event
A fix was merged to main in PR #12259. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #12259, #15348. That is separate from the fix event.
Still open
2 issues and 0 PRs.

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.

Open issues2

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

Open PRs0

No open pull requests.

Fixes on record

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

  • #12259 fix(context_compressor): keep tool-call arguments JSON valid when shrinking

    merged to main

    files: agent/context_compressor.py, scripts/release.py, tests/agent/test_context_compressor.py

    Show the diff
    • agent/context_compressor.py
      @@ -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.
       
      @@ -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"):
      @@ -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
      @@ -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
      @@ -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]")
  • #13005 fix(agent): repair malformed tool_call arguments before API send

    merged to main

    files: run_agent.py, scripts/release.py, tests/run_agent/test_repair_tool_call_arguments.py

    Show the diff
    • run_agent.py
      @@ -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.
       
      @@ -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
      @@ -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
      @@ -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)
  • #15348 fix(run_agent): persist tool_call argument repairs into session history

    merged to main

    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

    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

    files: agent/agent_runtime_helpers.py, tests/run_agent/test_run_agent.py

    Show the diff
    • agent/agent_runtime_helpers.py
      @@ -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
      @@ -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.

20 PRs closed without merging

From the close comments: done elsewhere 7 · duplicate 3 · superseded by another PR 3 · merged via another change 1 · stale or abandoned 1 · other 5

  • #4724 fix: validate tool calls before persisting to prevent session poisoning · superseded by another PR → #14407
  • #4838 fix(agent): sanitize malformed tool calls before persist and replay · other
  • #5071 fix(agent): filter malformed tool calls before persistence and replay · done elsewhere → #12259, #15348, #4662
  • #12093 fix(memory): handle unescaped apostrophes in tool call JSON from llama.cpp · other → 2d444fc
  • #12437 fix(context_compressor): properly truncate tool_calls to preserve valid JSON · done elsewhere
Show 15 more
  • #13057 fix(session): validate and repair tool_call arguments before persisting · superseded by another PR → #14383
  • #14383 fix(session): validate and repair tool_call arguments on both shapes (supersedes #13057) · done elsewhere → #13057, #12731, #15352, #13005
  • #14455 fix: sanitize malformed tool_calls and orphan tool results in outbound messages · other → #14444, #14443
  • #15241 fix(run_agent): repair corrupted tool_call arguments before sending to provider · merged via another change → 7a192b1, #15348
  • #18650 fix(compression): sanitize malformed tool messages + auto-recover on API 400 · done elsewhere → 812ce0b, #21385
  • #54856 fix(agent): heal empty-name tool calls — drop on persist, heal on session load (salvage #5071) · done elsewhere → 0cebf99, #55922, #47967, #12807
  • #58065 fix(agent): require object tool-call arguments · other → 0d8b5f4
  • #58076 fix(agent): sanitize tool_call arguments that are JSON arrays, not ob… · done elsewhere → #58168, #58065
  • #58092 fix: validate JSON type in sanitize_tool_call_arguments — reject non-dict arguments · superseded by another PR → #58065, #58057, #58076
  • #58124 fix: sanitize_tool_call_arguments now validates JSON type and unwraps single-element arrays · stale or abandoned
  • #58164 fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057) · duplicate → #58065, #58057, #58076, #58092, #58124
  • #66885 fix(agent): validate tool_call.arguments is a JSON object (not array/scalar) — #58057 · duplicate → #58057, #58065
  • #69100 fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057) · done elsewhere → #58057
  • #74477 fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057) · duplicate → #58065
  • #80594 fix(agent): validate tool_call argument type and unwrap single-element arrays (#58057) · other
Closed issues · 9
  • Closed#4662[Bug]: Malformed persisted tool calls can poison a session and cause repeated 400 errors on subsequent requests
  • Closed#10398[Bug]: 飞书发送消息回复错误
  • Closed#12068[Bug]: Memory save tool call fails JSON parse on llama.cpp — unescaped apostrophes in summary string
  • Closed#12167[MiniMax] Tool call parameter error - HTTP 400 with code 2013
  • Closed#12242[Bug] StepFun API: Unterminated string error caused by broken JSON in context_compressor
  • Closed#12731Session compression corrupts tool_call arguments, truncating JSON to 214 chars
  • Closed#13574[BUG] Context compaction truncates tool_call.function.arguments mid-string, produces invalid JSON, causes 400 errors with strict providers (e.g. MiniMax)
  • Closed#14443Truncated tool_call.arguments in conversation history wedges the retry loop
  • Closed#15236Compressed sessions with corrupted tool_calls.arguments JSON brick chats with HTTP 400 (invalid_tool_call_format)
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.05: 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
#12259, #13005, #15348, #15352, #55922
Closed issues
9
Attempts
20 PRs closed without merging
Fix link checked
30 Jun 2026

For your agent

The same facts, typed.