{"repo":"NousResearch/hermes-agent","public_id":"4662","display_label":"Malformed tool-call arguments sanitization and repair","summary":"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.","subsystem":"agent","counts":{"members":36,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":9,"merged_prs":5,"attempt_prs":20},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12259},"resolution":{"state":"partially_resolved","by":"pr-12259,pr-15348"},"freshness":{"state":"verified","last_verified_at":"2026-06-30T22:57:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.050285714285714274},"fix_edges":[{"issue_number":4662,"pr_number":12259,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4662,"pr_number":15348,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4662,"pr_number":55922,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":11001,"pr_number":12259,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11411,"pr_number":55922,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12167,"pr_number":12259,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12731,"pr_number":13005,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12731,"pr_number":15352,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":15236,"pr_number":15348,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":49968,"pr_number":55922,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":4662,"title":"[Bug]: Malformed persisted tool calls can poison a session and cause repeated 400 errors on subsequent requests","url":null,"state":"closed"},{"kind":"issue","number":10398,"title":"[Bug]: 飞书发送消息回复错误","url":null,"state":"closed"},{"kind":"issue","number":12068,"title":"[Bug]: Memory save tool call fails JSON parse on llama.cpp — unescaped apostrophes in summary string","url":null,"state":"closed"},{"kind":"issue","number":12167,"title":"[MiniMax] Tool call parameter error - HTTP 400 with code 2013","url":null,"state":"closed"},{"kind":"issue","number":12242,"title":"[Bug] StepFun API: Unterminated string error caused by broken JSON in context_compressor","url":null,"state":"closed"},{"kind":"issue","number":12731,"title":"Session compression corrupts tool_call arguments, truncating JSON to 214 chars","url":null,"state":"closed"},{"kind":"issue","number":13480,"title":"[Bug]: JSON Parsing Error in Tool Call Arguments","url":null,"state":"open"},{"kind":"issue","number":13574,"title":"[BUG] Context compaction truncates tool_call.function.arguments mid-string, produces invalid JSON, causes 400 errors with strict providers (e.g. MiniMax)","url":null,"state":"closed"},{"kind":"issue","number":14443,"title":"Truncated tool_call.arguments in conversation history wedges the retry loop","url":null,"state":"closed"},{"kind":"issue","number":15236,"title":"Compressed sessions with corrupted tool_calls.arguments JSON brick chats with HTTP 400 (invalid_tool_call_format)","url":null,"state":"closed"},{"kind":"issue","number":58057,"title":"[Bug]: sanitize_tool_call_arguments does not validate JSON type — array arguments cause HTTP 400 on strict providers","url":null,"state":"open"},{"kind":"pr","number":4724,"title":"fix: validate tool calls before persisting to prevent session poisoning","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14407","number":14407}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":4838,"title":"fix(agent): sanitize malformed tool calls before persist and replay","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":5071,"title":"fix(agent): filter malformed tool calls before persistence and replay","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12259","number":12259},{"kind":"pr","value":"#15348","number":15348},{"kind":"pr","value":"#4662","number":4662}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12093,"title":"fix(memory): handle unescaped apostrophes in tool call JSON from llama.cpp","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2d444fc84","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":12259,"title":"fix(context_compressor): keep tool-call arguments JSON valid when shrinking","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","scripts/release.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -63,6 +63,52 @@\n _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600\n \n \n+def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:\n+    \"\"\"Shrink long string values inside a tool-call arguments JSON blob while\n+    preserving JSON validity.\n+\n+    The ``function.arguments`` field on a tool call is a JSON-encoded string\n+    passed through to the LLM provider; downstream providers strictly\n+    validate it and return a non-retryable 400 when it is not well-formed.\n+    An earlier implementation sliced the raw JSON at a fixed byte offset and\n+    appended ``...[truncated]`` — which routinely produced strings like::\n+\n+        {\"path\": \"/foo/bar\", \"content\": \"# long markdown\n+        ...[truncated]\n+\n+    i.e. an unterminated string and a missing closing brace. MiniMax, for\n+    example, rejects this with ``invalid function arguments json string``\n+    and the session gets stuck re-sending the same broken history on every\n+    turn. See issue #11762 for the observed loop.\n+\n+    This helper parses the arguments, shrinks long string leaves inside the\n+    parsed structure, and re-serialises. Non-string values (paths, ints,\n+    booleans) are preserved intact. If the arguments are not valid JSON\n+    to begin with — some model backends use non-JSON tool arguments — the\n+    original string is returned unchanged rather than replaced with\n+    something neither we nor the backend can parse.\n+    \"\"\"\n+    try:\n+        parsed = json.loads(args)\n+    except (ValueError, TypeError):\n+        return args\n+\n+    def _shrink(obj: Any) -> Any:\n+        if isinstance(obj, str):\n+            if len(obj) > head_chars:\n+                return obj[:head_chars] + \"...[truncated]\"\n+            return obj\n+        if isinstance(obj, dict):\n+            return {k: _shrink(v) for k, v in obj.items()}\n+        if isinstance(obj, list):\n+            return [_shrink(v) for v in obj]\n+        return obj\n+\n+    shrunken = _shrink(parsed)\n+    # ensure_ascii=False preserves CJK/emoji instead of bloating with \\uXXXX\n+    return json.dumps(shrunken, ensure_ascii=False)\n+\n+\n def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:\n     \"\"\"Create an informative 1-line summary of a tool call + result.\n ","@@ -449,6 +495,11 @@ def _prune_old_tool_results(\n         # Pass 3: Truncate large tool_call arguments in assistant messages\n         # outside the protected tail. write_file with 50KB content, for\n         # example, survives pruning entirely without this.\n+        #\n+        # The shrinking is done inside the parsed JSON structure so the\n+        # result remains valid JSON — otherwise downstream providers 400\n+        # on every subsequent turn until the broken call falls out of\n+        # the window. See ``_truncate_tool_call_args_json`` docstring.\n         for i in range(prune_boundary):\n             msg = result[i]\n             if msg.get(\"role\") != \"assistant\" or not msg.get(\"tool_calls\"):","@@ -459,8 +510,10 @@ def _prune_old_tool_results(\n                 if isinstance(tc, dict):\n                     args = tc.get(\"function\", {}).get(\"arguments\", \"\")\n                     if len(args) > 500:\n-                        tc = {**tc, \"function\": {**tc[\"function\"], \"arguments\": args[:200] + \"...[truncated]\"}}\n-                        modified = True\n+                        new_args = _truncate_tool_call_args_json(args)\n+                        if new_args != args:\n+                            tc = {**tc, \"function\": {**tc[\"function\"], \"arguments\": new_args}}\n+                            modified = True\n                 new_tcs.append(tc)\n             if modified:\n                 result[i] = {**msg, \"tool_calls\": new_tcs}"]},{"file":"scripts/release.py","hunks":["@@ -74,6 +74,7 @@\n     \"[redacted:email]\": \"davetist\",\n     \"[redacted:email]\": \"yyq4193\",\n     \"[redacted:email]\": \"Asunfly\",\n+    \"[redacted:email]\": \"honghua\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"asheriif\",\n     \"[redacted:email]\": \"liujinkun2025\","]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -781,3 +781,127 @@ def test_prune_without_token_budget_uses_message_count(self, budget_compressor):\n         # Tool at index 2 is outside the protected tail (last 3 = indices 2,3,4)\n         # so it might or might not be pruned depending on boundary\n         assert isinstance(pruned, int)\n+\n+\n+class TestTruncateToolCallArgsJson:\n+    \"\"\"Regression tests for #11762.\n+\n+    The previous implementation produced invalid JSON by slicing\n+    ``function.arguments`` mid-string, which caused non-retryable 400s from\n+    strict providers (observed on MiniMax) and stuck long sessions in a\n+    re-send loop. The helper here must always emit parseable JSON whose\n+    shape matches the original — shrunken, not corrupted.\n+    \"\"\"\n+\n+    def _helper(self):\n+        from agent.context_compressor import _truncate_tool_call_args_json\n+        return _truncate_tool_call_args_json\n+\n+    def test_shrunken_args_remain_valid_json(self):\n+        import json as _json\n+        shrink = self._helper()\n+        original = _json.dumps({\n+            \"path\": \"~/.hermes/skills/shopping/browser-setup-notes.md\",\n+            \"content\": \"# Shopping Browser Setup Notes\\n\\n\" + \"abc \" * 400,\n+        })\n+        assert len(original) > 500\n+        shrunk = shrink(original)\n+        parsed = _json.loads(shrunk)  # must not raise\n+        assert parsed[\"path\"] == \"~/.hermes/skills/shopping/browser-setup-notes.md\"\n+        assert parsed[\"content\"].endswith(\"...[truncated]\")\n+        assert len(shrunk) < len(original)\n+\n+    def test_non_json_arguments_pass_through(self):\n+        shrink = self._helper()\n+        not_json = \"this is not json at all, \" * 50\n+        assert shrink(not_json) == not_json\n+\n+    def test_short_string_leaves_unchanged(self):\n+        import json as _json\n+        shrink = self._helper()\n+        payload = _json.dumps({\"command\": \"ls -la\", \"cwd\": \"/tmp\"})\n+        assert _json.loads(shrink(payload)) == {\"command\": \"ls -la\", \"cwd\": \"/tmp\"}\n+\n+    def test_nested_structures_are_walked(self):\n+        import json as _json\n+        shrink = self._helper()\n+        payload = _json.dumps({\n+            \"messages\": [\n+                {\"role\": \"user\", \"content\": \"x\" * 500},\n+                {\"role\": \"assistant\", \"content\": \"ok\"},\n+            ],\n+            \"meta\": {\"note\": \"y\" * 500},\n+        })\n+        parsed = _json.loads(shrink(payload))\n+        assert parsed[\"messages\"][0][\"content\"].endswith(\"...[truncated]\")\n+        assert parsed[\"messages\"][1][\"content\"] == \"ok\"\n+        assert parsed[\"meta\"][\"note\"].endswith(\"...[truncated]\")\n+\n+    def test_non_string_leaves_preserved(self):\n+        import json as _json\n+        shrink = self._helper()\n+        payload = _json.dumps({\n+            \"retries\": 3,\n+            \"enabled\": True,\n+            \"timeout\": None,\n+            \"items\": [1, 2, 3],\n+            \"note\": \"z\" * 500,\n+        })\n+        parsed = _json.loads(shrink(payload))\n+        assert parsed[\"retries\"] == 3\n+        assert parsed[\"enabled\"] is True\n+        assert parsed[\"timeout\"] is None\n+        assert parsed[\"items\"] == [1, 2, 3]\n+        assert parsed[\"note\"].endswith(\"...[truncated]\")\n+\n+    def test_scalar_json_string_gets_shrunk(self):\n+        import json as _json\n+        shrink = self._helper()\n+        payload = _json.dumps(\"q\" * 500)\n+        parsed = _json.loads(shrink(payload))\n+        assert isinstance(parsed, str)\n+        assert parsed.endswith(\"...[truncated]\")\n+\n+    def test_unicode_preserved(self):\n+        import json as _json\n+        shrink = self._helper()\n+        payload = _json.dumps({\"content\": \"非德满\" + (\"a\" * 500)})\n+        out = shrink(payload)\n+        # ensure_ascii=False keeps CJK intact rather than emitting \\uXXXX\n+        assert \"非德满\" in out\n+\n+    def test_pass3_emits_valid_json_for_downstream_provider(self):\n+        \"\"\"End-to-end: Pass 3 must never produce the exact failure payload\n+        that caused the 400 loop (unterminated string, missing brace).\"\"\"\n+        import json as _json\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test/model\",\n+                threshold_percent=0.85,\n+                protect_first_n=1,\n+                protect_last_n=1,\n+                quiet_mode=True,\n+            )\n+        huge_content = \"# Shopping Browser Setup Notes\\n\\n## Overview\\n\" + \"x \" * 400\n+        args_payload = _json.dumps({\n+            \"path\": \"~/.hermes/skills/shopping/browser-setup-notes.md\",\n+            \"content\": huge_content,\n+        })\n+        assert len(args_payload) > 500  # triggers the Pass-3 shrink\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"please write two files\"},\n+            {\"role\": \"assistant\", \"content\": None, \"tool_calls\": [\n+                {\"id\": \"call_1\", \"type\": \"function\",\n+                 \"function\": {\"name\": \"write_file\", \"arguments\": args_payload}},\n+            ]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\",\n+             \"content\": '{\"bytes_written\": 727}'},\n+            {\"role\": \"user\", \"content\": \"ok\"},\n+            {\"role\": \"assistant\", \"content\": \"done\"},\n+        ]\n+        result, _ = c._prune_old_tool_results(messages, protect_tail_count=2)\n+        shrunk = result[1][\"tool_calls\"][0][\"function\"][\"arguments\"]\n+        # Must parse — otherwise downstream provider returns 400\n+        parsed = _json.loads(shrunk)\n+        assert parsed[\"path\"] == \"~/.hermes/skills/shopping/browser-setup-notes.md\"\n+        assert parsed[\"content\"].endswith(\"...[truncated]\")\n"]}]},{"kind":"pr","number":12437,"title":"fix(context_compressor): properly truncate tool_calls to preserve valid JSON","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13005,"title":"fix(agent): repair malformed tool_call arguments before API send","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_repair_tool_call_arguments.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -555,6 +555,71 @@ def _sanitize_messages_surrogates(messages: list) -> bool:\n     return found\n \n \n+def _repair_tool_call_arguments(raw_args: str, tool_name: str = \"?\") -> str:\n+    \"\"\"Attempt to repair malformed tool_call argument JSON.\n+\n+    Models like GLM-5.1 via Ollama can produce truncated JSON, trailing\n+    commas, Python ``None``, etc.  The API proxy rejects these with HTTP 400\n+    \"invalid tool call arguments\".  This function applies common repairs;\n+    if all fail it returns ``\"{}\"`` so the request succeeds (better than\n+    crashing the session).  All repairs are logged at WARNING level.\n+    \"\"\"\n+    raw_stripped = raw_args.strip() if isinstance(raw_args, str) else \"\"\n+\n+    # Fast-path: empty / whitespace-only -> empty object\n+    if not raw_stripped:\n+        logger.warning(\"Sanitized empty tool_call arguments for %s\", tool_name)\n+        return \"{}\"\n+\n+    # Python-literal None -> normalise to {}\n+    if raw_stripped == \"None\":\n+        logger.warning(\"Sanitized Python-None tool_call arguments for %s\", tool_name)\n+        return \"{}\"\n+\n+    # Attempt common JSON repairs\n+    fixed = raw_stripped\n+    # 1. Strip trailing commas before } or ]\n+    fixed = re.sub(r',\\s*([}\\]])', r'\\1', fixed)\n+    # 2. Close unclosed structures\n+    open_curly = fixed.count('{') - fixed.count('}')\n+    open_bracket = fixed.count('[') - fixed.count(']')\n+    if open_curly > 0:\n+        fixed += '}' * open_curly\n+    if open_bracket > 0:\n+        fixed += ']' * open_bracket\n+    # 3. Remove excess closing braces/brackets (bounded to 50 iterations)\n+    for _ in range(50):\n+        try:\n+            json.loads(fixed)\n+            break\n+        except json.JSONDecodeError:\n+            if fixed.endswith('}') and fixed.count('}') > fixed.count('{'):\n+                fixed = fixed[:-1]\n+            elif fixed.endswith(']') and fixed.count(']') > fixed.count('['):\n+                fixed = fixed[:-1]\n+            else:\n+                break\n+\n+    try:\n+        json.loads(fixed)\n+        logger.warning(\n+            \"Repaired malformed tool_call arguments for %s: %s → %s\",\n+            tool_name, raw_stripped[:80], fixed[:80],\n+        )\n+        return fixed\n+    except json.JSONDecodeError:\n+        pass\n+\n+    # Last resort: replace with empty object so the API request doesn't\n+    # crash the entire session.\n+    logger.warning(\n+        \"Unrepairable tool_call arguments for %s — \"\n+        \"replaced with empty object (was: %s)\",\n+        tool_name, raw_stripped[:80],\n+    )\n+    return \"{}\"\n+\n+\n def _strip_non_ascii(text: str) -> str:\n     \"\"\"Remove non-ASCII characters, replacing with closest ASCII equivalent or removing.\n ","@@ -9662,7 +9727,10 @@ def run_conversation(\n                                 ),\n                             }}\n                         except Exception:\n-                            pass\n+                            tc[\"function\"][\"arguments\"] = _repair_tool_call_arguments(\n+                                tc[\"function\"][\"arguments\"],\n+                                tc[\"function\"].get(\"name\", \"?\"),\n+                            )\n                     new_tcs.append(tc)\n                 am[\"tool_calls\"] = new_tcs\n "]},{"file":"scripts/release.py","hunks":["@@ -66,6 +66,8 @@\n     \"[redacted:email]\": \"Sertug17\",\n     \"[redacted:email]\": \"caentzminger\",\n     \"[redacted:email]\": \"voidborne-d\",\n+    \"[redacted:email]\": \"sirEven\",\n+    \"[redacted:email]\": \"sirEven\",\n     \"[redacted:email]\": \"insecurejezza\",\n     \"[redacted:email]\": \"dodo-reach\",\n     \"[redacted:email]\": \"Bartok9\","]},{"file":"tests/run_agent/test_repair_tool_call_arguments.py","hunks":["@@ -0,0 +1,107 @@\n+\"\"\"Tests for _repair_tool_call_arguments — malformed JSON repair pipeline.\"\"\"\n+\n+import json\n+import pytest\n+\n+from run_agent import _repair_tool_call_arguments\n+\n+\n+class TestRepairToolCallArguments:\n+    \"\"\"Verify each repair stage in the pipeline.\"\"\"\n+\n+    # -- Stage 1: empty / whitespace-only --\n+\n+    def test_empty_string_returns_empty_object(self):\n+        assert _repair_tool_call_arguments(\"\", \"t\") == \"{}\"\n+\n+    def test_whitespace_only_returns_empty_object(self):\n+        assert _repair_tool_call_arguments(\"   \\n\\t  \", \"t\") == \"{}\"\n+\n+    def test_none_type_returns_empty_object(self):\n+        \"\"\"Non-string input (e.g. None from a broken model response).\"\"\"\n+        assert _repair_tool_call_arguments(None, \"t\") == \"{}\"\n+\n+    # -- Stage 2: Python None literal --\n+\n+    def test_python_none_literal(self):\n+        assert _repair_tool_call_arguments(\"None\", \"t\") == \"{}\"\n+\n+    def test_python_none_with_whitespace(self):\n+        assert _repair_tool_call_arguments(\"  None  \", \"t\") == \"{}\"\n+\n+    # -- Stage 3: trailing comma repair --\n+\n+    def test_trailing_comma_in_object(self):\n+        result = _repair_tool_call_arguments('{\"key\": \"value\",}', \"t\")\n+        assert json.loads(result) == {\"key\": \"value\"}\n+\n+    def test_trailing_comma_in_array(self):\n+        result = _repair_tool_call_arguments('{\"a\": [1, 2,]}', \"t\")\n+        parsed = json.loads(result)\n+        assert parsed == {\"a\": [1, 2]}\n+\n+    def test_multiple_trailing_commas(self):\n+        result = _repair_tool_call_arguments('{\"a\": 1, \"b\": 2,}', \"t\")\n+        parsed = json.loads(result)\n+        assert parsed[\"a\"] == 1\n+        assert parsed[\"b\"] == 2\n+\n+    # -- Stage 4: unclosed brackets --\n+\n+    def test_unclosed_brace(self):\n+        result = _repair_tool_call_arguments('{\"key\": \"value\"', \"t\")\n+        parsed = json.loads(result)\n+        assert parsed == {\"key\": \"value\"}\n+\n+    def test_unclosed_bracket_and_brace(self):\n+        result = _repair_tool_call_arguments('{\"a\": [1, 2', \"t\")\n+        # Bracket counting adds ']' then '}', producing {\"a\": [1, 2]}\n+        # which is valid JSON.  But the naive count can't always recover\n+        # complex nesting — verify we at least get valid JSON.\n+        json.loads(result)\n+\n+    # -- Stage 5: excess closing delimiters --\n+\n+    def test_extra_closing_brace(self):\n+        result = _repair_tool_call_arguments('{\"key\": \"value\"}}', \"t\")\n+        parsed = json.loads(result)\n+        assert parsed == {\"key\": \"value\"}\n+\n+    def test_extra_closing_bracket(self):\n+        result = _repair_tool_call_arguments('{\"a\": [1]]}', \"t\")\n+        # Should produce valid JSON\n+        json.loads(result)\n+\n+    # -- Stage 6: last resort --\n+\n+    def test_unrepairable_garbage_returns_empty_object(self):\n+        assert _repair_tool_call_arguments(\"totally not json\", \"t\") == \"{}\"\n+\n+    def test_unrepairable_partial_returns_empty_object(self):\n+        # Truncated in the middle of a string key — bracket closing won't help\n+        assert _repair_tool_call_arguments('{\"truncated\": \"val', \"t\") == \"{}\"\n+\n+    # -- Valid JSON passthrough (this path is via except, but still works) --\n+\n+    def test_already_valid_json_passes_through(self):\n+        \"\"\"When json.loads fails for a non-JSON reason (shouldn't normally\n+        happen), but the repair pipeline still produces valid output.\"\"\"\n+        raw = '{\"path\": \"/tmp/foo\", \"content\": \"hello\"}'\n+        result = _repair_tool_call_arguments(raw, \"t\")\n+        parsed = json.loads(result)\n+        assert parsed[\"path\"] == \"/tmp/foo\"\n+\n+    # -- Combined repairs --\n+\n+    def test_trailing_comma_plus_unclosed_brace(self):\n+        result = _repair_tool_call_arguments('{\"a\": 1, \"b\": 2,', \"t\")\n+        # Trailing comma stripped first, then closing brace added.\n+        # May or may not fully recover — verify valid JSON at minimum.\n+        json.loads(result)\n+\n+    def test_real_world_glm_truncation(self):\n+        \"\"\"Simulates GLM-5.1 truncating mid-argument.\"\"\"\n+        raw = '{\"command\": \"ls -la /tmp\", \"timeout\": 30, \"background\":'\n+        result = _repair_tool_call_arguments(raw, \"terminal\")\n+        # Should at least be valid JSON, even if background is lost\n+        json.loads(result)\n"]}]},{"kind":"pr","number":13057,"title":"fix(session): validate and repair tool_call arguments before persisting","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14383","number":14383},{"kind":"pr","value":"#14383","number":14383}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":14383,"title":"fix(session): validate and repair tool_call arguments on both shapes (supersedes #13057)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#13057","number":13057},{"kind":"pr","value":"#12731","number":12731},{"kind":"pr","value":"#15352","number":15352},{"kind":"pr","value":"#13005","number":13005},{"kind":"pr","value":"#12731","number":12731}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14455,"title":"fix: sanitize malformed tool_calls and orphan tool results in outbound messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14444","number":14444},{"kind":"pr","value":"#14443","number":14443}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15241,"title":"fix(run_agent): repair corrupted tool_call arguments before sending to provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7a192b124","number":0},{"kind":"pr","value":"#15348","number":15348}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15348,"title":"fix(run_agent): persist tool_call argument repairs into session history","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_tool_call_args_sanitizer.py"]},{"kind":"pr","number":15352,"title":"fix(run_agent): repair malformed tool_call args during streaming assembly","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_streaming_tool_call_repair.py"]},{"kind":"pr","number":18650,"title":"fix(compression): sanitize malformed tool messages + auto-recover on API 400","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"812ce0b9878d1dc9ac1f7c419a620deeb57117f3","number":0},{"kind":"pr","value":"#21385","number":21385}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54856,"title":"fix(agent): heal empty-name tool calls — drop on persist, heal on session load (salvage #5071)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0cebf994c965672090a58a0dec3b78f565fbaac5","number":0},{"kind":"pr","value":"#55922","number":55922},{"kind":"pr","value":"#55922","number":55922},{"kind":"pr","value":"#47967","number":47967},{"kind":"pr","value":"#12807","number":12807}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55922,"title":"fix(agent): repair empty-name tool_calls in sanitizer to prevent Responses 400 (salvage #12807/#52893)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -2257,6 +2257,54 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]\n         filtered.append(msg)\n     messages = filtered\n \n+    # --- Repair tool_calls whose function.name is empty/missing ---\n+    # Some providers (and partially-streamed responses) emit a tool_call with\n+    # id=\"call_xxx\" but function.name=\"\". Downstream Responses-API adapters\n+    # silently DROP such function_call items while still emitting the matching\n+    # function_call_output, producing the gateway's HTTP 400\n+    # \"No tool call found for function call output with call_id ...\".\n+    #\n+    # We do NOT drop the call: hermes' own dispatch loop intentionally keeps an\n+    # empty-name call paired with a synthesized anti-priming tool result\n+    # (\"tool name was empty\", see #47967) so weak models self-correct instead of\n+    # being fed the full tool catalog. Dropping the call here would (a) orphan\n+    # that result and strip the anti-priming signal, and (b) still leave any\n+    # provider-side orphan. Instead, rename the blank name to a non-empty\n+    # sentinel so the call and its result stay PAIRED — the adapter no longer\n+    # drops the function_call, so there is no orphaned output and no 400, while\n+    # the result content the model needs is preserved.\n+    _EMPTY_NAME_SENTINEL = \"invalid_tool_call\"\n+    for msg in messages:\n+        if msg.get(\"role\") != \"assistant\":\n+            continue\n+        tcs = msg.get(\"tool_calls\") or []\n+        if not tcs:\n+            continue\n+        for tc in tcs:\n+            if isinstance(tc, dict):\n+                fn = tc.get(\"function\")\n+                name = fn.get(\"name\") if isinstance(fn, dict) else getattr(fn, \"name\", None)\n+            else:\n+                fn = getattr(tc, \"function\", None)\n+                name = getattr(fn, \"name\", None) if fn else None\n+            if isinstance(name, str) and name.strip():\n+                continue\n+            _ra().logger.warning(\n+                \"Pre-call sanitizer: repairing tool_call with empty \"\n+                \"function.name -> %r (id=%s)\",\n+                _EMPTY_NAME_SENTINEL,\n+                _ra().AIAgent._get_tool_call_id_static(tc),\n+            )\n+            if isinstance(fn, dict):\n+                fn[\"name\"] = _EMPTY_NAME_SENTINEL\n+            elif fn is not None and hasattr(fn, \"name\"):\n+                try:\n+                    fn.name = _EMPTY_NAME_SENTINEL\n+                except Exception:\n+                    pass\n+            elif isinstance(tc, dict):\n+                tc[\"function\"] = {\"name\": _EMPTY_NAME_SENTINEL, \"arguments\": \"{}\"}\n+\n     surviving_call_ids: set = set()\n     for msg in messages:\n         if msg.get(\"role\") == \"assistant\":"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -3659,6 +3659,48 @@ def test_api_sanitizer_matches_responses_call_id_when_id_differs(self, agent):\n             \"call_123\"\n         ]\n \n+    def test_api_sanitizer_repairs_tool_call_with_empty_function_name(self, agent):\n+        \"\"\"A tool_call with id but empty function.name makes the Responses-API\n+        adapter drop the function_call while keeping its function_call_output,\n+        causing the gateway's HTTP 400 'No tool call found for function call\n+        output ...'. The sanitizer renames the blank name to a non-empty\n+        sentinel so the call and its result stay PAIRED (no orphaned output,\n+        no 400) while the result content is preserved — it must NOT drop the\n+        call, because hermes' dispatch loop keeps empty-name calls paired with\n+        an anti-priming result for self-correction (#47967). (#12807)\"\"\"\n+        messages = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_good\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"web_search\", \"arguments\": \"{}\"},\n+                    },\n+                    {\n+                        \"id\": \"call_bad\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"\", \"arguments\": \"{}\"},\n+                    },\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_good\", \"content\": \"ok\"},\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_bad\", \"content\": \"orphan\"},\n+        ]\n+\n+        sanitized = agent._sanitize_api_messages(messages)\n+\n+        # The good call is untouched; the malformed call is repaired in place\n+        # (renamed to a non-empty sentinel) rather than dropped.\n+        assistant = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        names = [tc[\"function\"][\"name\"] for tc in assistant[\"tool_calls\"]]\n+        assert names == [\"web_search\", \"invalid_tool_call\"]\n+        # Both calls now have non-empty names, so neither output is orphaned\n+        # and both tool results survive — this is what prevents the 400.\n+        tool_ids = [m.get(\"tool_call_id\") for m in sanitized if m.get(\"role\") == \"tool\"]\n+        assert tool_ids == [\"call_good\", \"call_bad\"]\n+\n \n class TestRunConversation:\n     \"\"\"Tests for the main run_conversation method.\n"]}]},{"kind":"pr","number":58065,"title":"fix(agent): require object tool-call arguments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0d8b5f4a4a72","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58076,"title":"fix(agent): sanitize tool_call arguments that are JSON arrays, not ob…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58168","number":58168},{"kind":"pr","value":"#58065","number":58065}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58092,"title":"fix: validate JSON type in sanitize_tool_call_arguments — reject non-dict arguments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58065","number":58065},{"kind":"pr","value":"#58057","number":58057},{"kind":"pr","value":"#58065","number":58065},{"kind":"pr","value":"#58076","number":58076},{"kind":"pr","value":"#58065","number":58065},{"kind":"pr","value":"#58076","number":58076},{"kind":"pr","value":"#58057","number":58057}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58124,"title":"fix: sanitize_tool_call_arguments now validates JSON type and unwraps single-element arrays","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":58164,"title":"fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58065","number":58065},{"kind":"pr","value":"#58057","number":58057},{"kind":"pr","value":"#58065","number":58065},{"kind":"pr","value":"#58076","number":58076},{"kind":"pr","value":"#58092","number":58092},{"kind":"pr","value":"#58124","number":58124},{"kind":"pr","value":"#58057","number":58057},{"kind":"pr","value":"#58065","number":58065}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66885,"title":"fix(agent): validate tool_call.arguments is a JSON object (not array/scalar) — #58057","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58057","number":58057},{"kind":"pr","value":"#58065","number":58065}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":69100,"title":"fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58057","number":58057}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":74477,"title":"fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58065","number":58065},{"kind":"pr","value":"#58065","number":58065}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":80594,"title":"fix(agent): validate tool_call argument type and unwrap single-element arrays (#58057)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}