{"repo":"NousResearch/hermes-agent","public_id":"759","display_label":"AttributeError crashes when content or args are dicts/lists","summary":"Multiple bugs and fixes related to code paths that assume string types but receive dicts or lists instead, particularly in streaming content deltas, tool call arguments, and compression/summarization logic. Affects various backends including Mistral, NVIDIA, llama.cpp, and Ollama.","subsystem":"agent","counts":{"members":19,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":4,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1440},"resolution":{"state":"partially_resolved","by":"pr-63755"},"freshness":{"state":"verified","last_verified_at":"2026-09-07T15:24:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09403508771929824},"fix_edges":[{"issue_number":1071,"pr_number":1440,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":58317,"pr_number":64772,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":63734,"pr_number":63755,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":63734,"pr_number":104994,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":104711,"pr_number":104994,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":759,"title":"[Bug]: AttributeError \"dict' object has no attribute 'strip\" when using OpenAI endpoint","url":null,"state":"closed"},{"kind":"issue","number":1071,"title":"[Bug]: Error during OpenAI-compatible API call #1: 'dict' object has no attribute 'strip'","url":null,"state":"closed"},{"kind":"issue","number":58317,"title":"Compression crash: AttributeError 'dict' object has no attribute 'count' in _summarize_tool_result","url":null,"state":"closed"},{"kind":"issue","number":63734,"title":"AttributeError: 'list' object has no attribute 'strip' during streaming follow-up after a tool call (Mistral / NVIDIA custom provider)","url":null,"state":"closed"},{"kind":"pr","number":1076,"title":"fix: handle dict tool call arguments from llama.cpp/Ollama backends","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":1080,"title":"fix: handle dict tool call arguments from llama.cpp/Ollama backends","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":1081,"title":"fix: handle dict tool call arguments from llama.cpp/Ollama backends","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1440","number":1440}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1210,"title":"fix: handle dict arguments in tool call validation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1440","number":1440}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":1440,"title":"fix: handle dict tool call arguments from local backends","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/test_dict_tool_call_args.py"]},{"kind":"pr","number":58333,"title":"fix(agent): harden compressor tool arg summaries","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64772","number":64772},{"kind":"pr","value":"#52431","number":52431},{"kind":"pr","value":"#58317","number":58317}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":58442,"title":"fix(compression): prevent AttributeError crash on dict tool arguments in _summarize_tool_result (#58317)","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58595,"title":"fix(compression): handle dict/list content in write_file summarizer","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64772","number":64772},{"kind":"pr","value":"#52431","number":52431}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":62168,"title":"fix(compressor): handle dict content in _summarize_tool_result","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58333","number":58333},{"kind":"pr","value":"#58333","number":58333},{"kind":"pr","value":"#58317","number":58317},{"kind":"pr","value":"#52431","number":52431}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63755,"title":"fix(agent): normalize list-shaped streaming content deltas","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","tests/run_agent/test_streaming.py"]},{"kind":"pr","number":64047,"title":"fix(agent): normalize list-type delta.content on streaming follow-up","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63755","number":63755}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":64772,"title":"fix: display layers never crash on non-string tool-call arguments","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/tools.py","agent/context_compressor.py","agent/display.py","scripts/release.py","tests/acp/test_tools.py","tests/agent/test_summarize_tool_result_type_safety.py"],"fix_hunks":[{"file":"acp_adapter/tools.py","hunks":["@@ -3,6 +3,7 @@\n from __future__ import annotations\n \n import json\n+import logging\n import uuid\n from typing import Any, Dict, List, Optional\n ","@@ -14,6 +15,8 @@\n     ToolKind,\n )\n \n+logger = logging.getLogger(__name__)\n+\n # ---------------------------------------------------------------------------\n # Map hermes tool names -> ACP ToolKind\n # ---------------------------------------------------------------------------","@@ -1026,7 +1029,37 @@ def build_tool_start(\n     *,\n     edit_diff: Any = None,\n ) -> ToolCallStart:\n-    \"\"\"Create a ToolCallStart event for the given hermes tool invocation.\"\"\"\n+    \"\"\"Create a ToolCallStart event for the given hermes tool invocation.\n+\n+    A malformed tool argument (e.g. a non-string ``command``/``path`` from a\n+    model that ignores the schema) must never abort the ACP tool-call render —\n+    ``build_tool_start`` runs on the live tool-progress callback and during\n+    session history replay. On any failure in the title/content/location\n+    builders, fall back to a minimal, valid start event. Mirrors\n+    ``get_cute_tool_message`` in ``agent/display.py``, wrapped for the same\n+    reason on the CLI side.\n+    \"\"\"\n+    try:\n+        return _build_tool_start(\n+            tool_call_id, tool_name, arguments, edit_diff=edit_diff\n+        )\n+    except Exception as exc:  # noqa: BLE001 — a tool-call render must never abort the turn\n+        logger.debug(\"ACP tool-start render failed for %r: %s\", tool_name, exc)\n+        safe_name = tool_name if isinstance(tool_name, str) and tool_name else \"tool\"\n+        return acp.start_tool_call(\n+            tool_call_id, safe_name, kind=get_tool_kind(safe_name),\n+            content=None, locations=[], raw_input=None,\n+        )\n+\n+\n+def _build_tool_start(\n+    tool_call_id: str,\n+    tool_name: str,\n+    arguments: Dict[str, Any],\n+    *,\n+    edit_diff: Any = None,\n+) -> ToolCallStart:\n+    \"\"\"Build the ToolCallStart event (unguarded; see ``build_tool_start``).\"\"\"\n     kind = get_tool_kind(tool_name)\n     title = build_tool_title(tool_name, arguments)\n     locations = extract_locations(arguments)"]},{"file":"agent/context_compressor.py","hunks":["@@ -586,6 +586,21 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An\n     return result if changed else messages\n \n \n+def _str_arg(args: dict, key: str, default: str = \"\") -> str:\n+    \"\"\"Safely get a string argument from parsed tool args.\n+\n+    LLMs sometimes return non-string parameter values (e.g. bool, int) for\n+    tool calls.  Calling ``len()`` / ``.count()`` / slicing on those causes\n+    ``TypeError`` / ``AttributeError`` which crashes context compression.\n+    This helper coerces any value to ``str`` so downstream code can assume\n+    a string is always returned.\n+    \"\"\"\n+    val = args.get(key, default)\n+    if isinstance(val, str):\n+        return val\n+    return str(val) if val is not None else default\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 ","@@ -598,18 +613,37 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->\n         [terminal] ran `npm test` -> exit 0, 47 lines output\n         [read_file] read config.py from line 1 (1,200 chars)\n         [search_files] content search for 'compress' in agent/ -> 12 matches\n+\n+    Never raises: models sometimes emit non-string argument values (bool,\n+    int, None) and the args here come from persisted session history, so a\n+    single malformed historical call must not crash compression — which\n+    retries on the same history and would crash-loop. Individual branches\n+    coerce the values they slice/measure (keeping summaries informative);\n+    this wrapper is the backstop for anything they miss.\n     \"\"\"\n+    try:\n+        return _summarize_tool_result_unguarded(tool_name, tool_args, tool_content)\n+    except Exception as exc:  # noqa: BLE001 — a summary must never crash compression\n+        logger.debug(\"Tool-result summary failed for %s: %s\", tool_name, exc)\n+        _len = len(tool_content) if isinstance(tool_content, str) else 0\n+        return f\"[{tool_name}] ({_len:,} chars result)\"\n+\n+\n+def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_content: str) -> str:\n+    \"\"\"Build the summary line (unguarded; see ``_summarize_tool_result``).\"\"\"\n     try:\n         args = json.loads(tool_args) if tool_args else {}\n     except (json.JSONDecodeError, TypeError):\n         args = {}\n+    if not isinstance(args, dict):\n+        args = {}\n \n     content = tool_content or \"\"\n     content_len = len(content)\n     line_count = content.count(\"\\n\") + 1 if content.strip() else 0\n \n     if tool_name == \"terminal\":\n-        cmd = args.get(\"command\", \"\")\n+        cmd = _str_arg(args, \"command\")\n         if len(cmd) > 80:\n             cmd = cmd[:77] + \"...\"\n         exit_match = re.search(r'\"exit_code\"\\s*:\\s*(-?\\d+)', content)","@@ -623,7 +657,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->\n \n     if tool_name == \"write_file\":\n         path = args.get(\"path\", \"?\")\n-        written_lines = args.get(\"content\", \"\").count(\"\\n\") + 1 if args.get(\"content\") else \"?\"\n+        written_lines = _str_arg(args, \"content\").count(\"\\n\") + 1 if args.get(\"content\") else \"?\"\n         return f\"[write_file] wrote to {path} ({written_lines} lines)\"\n \n     if tool_name == \"search_files\":","@@ -658,14 +692,15 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->\n         return f\"[web_extract] {url_desc} ({content_len:,} chars)\"\n \n     if tool_name == \"delegate_task\":\n-        goal = args.get(\"goal\", \"\")\n+        goal = _str_arg(args, \"goal\")\n         if len(goal) > 60:\n             goal = goal[:57] + \"...\"\n         return f\"[delegate_task] '{goal}' ({content_len:,} chars result)\"\n \n     if tool_name == \"execute_code\":\n-        code_preview = (args.get(\"code\") or \"\")[:60].replace(\"\\n\", \" \")\n-        if len(args.get(\"code\", \"\")) > 60:\n+        code_str = _str_arg(args, \"code\")\n+        code_preview = code_str[:60].replace(\"\\n\", \" \")\n+        if len(code_str) > 60:\n             code_preview += \"...\"\n         return f\"[execute_code] `{code_preview}` ({line_count} lines output)\"\n ","@@ -674,7 +709,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->\n         return f\"[{tool_name}] name={name} ({content_len:,} chars)\"\n \n     if tool_name == \"vision_analyze\":\n-        question = args.get(\"question\", \"\")[:50]\n+        question = _str_arg(args, \"question\")[:50]\n         return f\"[vision_analyze] '{question}' ({content_len:,} chars)\"\n \n     if tool_name == \"memory\":"]},{"file":"agent/display.py","hunks":["@@ -462,13 +462,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -\n         sid = args.get(\"session_id\", \"\")\n         data = args.get(\"data\", \"\")\n         timeout_val = args.get(\"timeout\")\n-        parts = [action]\n+        parts = [str(action) if action else \"\"]\n         if sid:\n-            parts.append(sid[:16])\n+            parts.append(str(sid)[:16])\n         if data:\n-            parts.append(f'\"{_oneline(data[:20])}\"')\n+            parts.append(f'\"{_oneline(str(data)[:20])}\"')\n         if timeout_val and action == \"wait\":\n             parts.append(f\"{timeout_val}s\")\n+        parts = [p for p in parts if p]\n         return \" \".join(parts) if parts else None\n \n     if tool_name == \"todo\":"]},{"file":"scripts/release.py","hunks":["@@ -332,6 +332,8 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"liuwei666888\",\n+    \"[redacted:email]\": \"liuwei666888\",\n     \"[redacted:email]\": \"justinschille\",\n     \"[redacted:email]\": \"unsupportedpastels\",\n     \"[redacted:email]\": \"John-Lussier\","]},{"file":"tests/acp/test_tools.py","hunks":["@@ -227,6 +227,26 @@ def test_build_tool_start_for_read_file(self):\n         assert result.content is None\n         assert result.raw_input is None\n \n+    def test_build_tool_start_survives_non_string_command(self):\n+        \"\"\"A malformed (non-string) terminal command previously raised\n+        TypeError in build_tool_title (len(None)) and aborted the render.\"\"\"\n+        result = build_tool_start(\"tc-bad-cmd\", \"terminal\", {\"command\": None})\n+        assert isinstance(result, ToolCallStart)\n+        assert result.kind == \"execute\"  # tool identity preserved in the fallback\n+\n+    def test_build_tool_start_survives_non_string_path(self):\n+        \"\"\"A non-string read_file path previously raised a ToolCallLocation\n+        pydantic ValidationError in extract_locations and aborted the render.\"\"\"\n+        result = build_tool_start(\"tc-bad-path\", \"read_file\", {\"path\": {\"p\": \"x\"}})\n+        assert isinstance(result, ToolCallStart)\n+        assert result.kind == \"read\"\n+\n+    def test_build_tool_start_survives_non_string_goal(self):\n+        \"\"\"A non-string delegate_task goal previously raised TypeError\n+        (len(123)) in build_tool_title and aborted the render.\"\"\"\n+        result = build_tool_start(\"tc-bad-goal\", \"delegate_task\", {\"goal\": 123})\n+        assert isinstance(result, ToolCallStart)\n+\n     def test_build_tool_start_for_web_extract_is_compact(self):\n         \"\"\"web_extract start should stay compact; title identifies URLs.\"\"\"\n         args = {\"urls\": [\"https://example.com/docs\"]}"]},{"file":"tests/agent/test_summarize_tool_result_type_safety.py","hunks":["@@ -0,0 +1,286 @@\n+\"\"\"Type safety tests for _summarize_tool_result.\n+\n+When LLMs return non-string parameter values (e.g. bool, int, None) in tool\n+call arguments, _summarize_tool_result() must not crash with TypeError or\n+AttributeError. This caused an infinite TUI crash loop in production.\n+\"\"\"\n+import json\n+import pytest\n+from agent.context_compressor import _summarize_tool_result\n+\n+\n+class TestTypeSafety:\n+    \"\"\"Non-string tool arguments must not crash _summarize_tool_result.\"\"\"\n+\n+    def test_terminal_command_bool(self):\n+        \"\"\"bool value for 'command' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"command\": True})\n+        result = _summarize_tool_result(\"terminal\", args, '{\"exit_code\": 0}')\n+        assert \"terminal\" in result\n+        assert \"True\" in result or \"exit\" in result\n+\n+    def test_terminal_command_int(self):\n+        \"\"\"int value for 'command' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"command\": 42})\n+        result = _summarize_tool_result(\"terminal\", args, '{\"exit_code\": 0}')\n+        assert \"terminal\" in result\n+        assert \"42\" in result\n+\n+    def test_terminal_command_none(self):\n+        \"\"\"None value for 'command' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"command\": None})\n+        result = _summarize_tool_result(\"terminal\", args, '{\"exit_code\": 0}')\n+        assert \"terminal\" in result\n+\n+    def test_write_file_content_bool(self):\n+        \"\"\"bool value for 'content' should not raise AttributeError.\"\"\"\n+        args = json.dumps({\"path\": \"test.txt\", \"content\": False})\n+        result = _summarize_tool_result(\"write_file\", args, \"OK\")\n+        assert \"write_file\" in result\n+        assert \"test.txt\" in result\n+\n+    def test_write_file_content_int(self):\n+        \"\"\"int value for 'content' should not raise AttributeError.\"\"\"\n+        args = json.dumps({\"path\": \"test.txt\", \"content\": 123})\n+        result = _summarize_tool_result(\"write_file\", args, \"OK\")\n+        assert \"write_file\" in result\n+\n+    def test_delegate_task_goal_bool(self):\n+        \"\"\"bool value for 'goal' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"goal\": False})\n+        result = _summarize_tool_result(\"delegate_task\", args, \"result\")\n+        assert \"delegate_task\" in result\n+        assert \"False\" in result\n+\n+    def test_delegate_task_goal_int(self):\n+        \"\"\"int value for 'goal' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"goal\": 999})\n+        result = _summarize_tool_result(\"delegate_task\", args, \"result\")\n+        assert \"delegate_task\" in result\n+        assert \"999\" in result\n+\n+    def test_execute_code_code_bool(self):\n+        \"\"\"bool value for 'code' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"code\": True})\n+        result = _summarize_tool_result(\"execute_code\", args, \"output\")\n+        assert \"execute_code\" in result\n+        assert \"True\" in result\n+\n+    def test_execute_code_code_int(self):\n+        \"\"\"int value for 'code' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"code\": 0})\n+        result = _summarize_tool_result(\"execute_code\", args, \"output\")\n+        assert \"execute_code\" in result\n+\n+    def test_vision_analyze_question_bool(self):\n+        \"\"\"bool value for 'question' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"question\": True})\n+        result = _summarize_tool_result(\"vision_analyze\", args, \"analysis\")\n+        assert \"vision_analyze\" in result\n+        assert \"True\" in result\n+\n+    def test_vision_analyze_question_int(self):\n+        \"\"\"int value for 'question' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"question\": 123})\n+        result = _summarize_tool_result(\"vision_analyze\", args, \"analysis\")\n+        assert \"vision_analyze\" in result\n+        assert \"123\" in result\n+\n+    def test_vision_analyze_question_list(self):\n+        \"\"\"list value for 'question' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"question\": [\"a\", \"b\"]})\n+        result = _summarize_tool_result(\"vision_analyze\", args, \"analysis\")\n+        assert \"vision_analyze\" in result\n+\n+    def test_vision_analyze_question_dict(self):\n+        \"\"\"dict value for 'question' should not raise TypeError.\"\"\"\n+        args = json.dumps({\"question\": {\"key\": \"value\"}})\n+        result = _summarize_tool_result(\"vision_analyze\", args, \"analysis\")\n+        assert \"vision_analyze\" in result\n+\n+\n+class TestNormalStringArguments:\n+    \"\"\"Normal string arguments should continue to work as before.\"\"\"\n+\n+    def test_terminal_normal_command(self):\n+        \"\"\"Normal string command should be summarized correctly.\"\"\"\n+        args = json.dumps({\"command\": \"ls -la\"})\n+        result = _summarize_tool_result(\"terminal\", args, '{\"exit_code\": 0}')\n+        assert \"terminal\" in result\n+        assert \"ls -la\" in result\n+        assert \"exit 0\" in result\n+\n+    def test_terminal_long_command_truncated(self):\n+        \"\"\"Long commands should be truncated.\"\"\"\n+        long_cmd = \"a\" * 100\n+        args = json.dumps({\"command\": long_cmd})\n+        result = _summarize_tool_result(\"terminal\", args, '{\"exit_code\": 0}')\n+        assert \"...\" in result\n+        assert len(result) < 150\n+\n+    def test_write_file_normal_content(self):\n+        \"\"\"Normal string content should count lines correctly.\"\"\"\n+        args = json.dumps({\"path\": \"test.py\", \"content\": \"line1\\nline2\\nline3\"})\n+        result = _summarize_tool_result(\"write_file\", args, \"OK\")\n+        assert \"write_file\" in result\n+        assert \"test.py\" in result\n+        assert \"3 lines\" in result\n+\n+    def test_delegate_task_normal_goal(self):\n+        \"\"\"Normal string goal should be summarized correctly.\"\"\"\n+        args = json.dumps({\"goal\": \"Fix the bug\"})\n+        result = _summarize_tool_result(\"delegate_task\", args, \"done\")\n+        assert \"delegate_task\" in result\n+        assert \"Fix the bug\" in result\n+\n+    def test_delegate_task_long_goal_truncated(self):\n+        \"\"\"Long goals should be truncated.\"\"\"\n+        long_goal = \"x\" * 100\n+        args = json.dumps({\"goal\": long_goal})\n+        result = _summarize_tool_result(\"delegate_task\", args, \"done\")\n+        assert \"...\" in result\n+\n+    def test_execute_code_normal_code(self):\n+        \"\"\"Normal code should be previewed correctly.\"\"\"\n+        args = json.dumps({\"code\": \"print('hello')\"})\n+        result = _summarize_tool_result(\"execute_code\", args, \"hello\")\n+        assert \"execute_code\" in result\n+        assert \"print\" in result\n+\n+    def test_execute_code_long_code_truncated(self):\n+        \"\"\"Long code should be truncated.\"\"\"\n+        long_code = \"a = 1\\n\" * 20\n+        args = json.dumps({\"code\": long_code})\n+        result = _summarize_tool_result(\"execute_code\", args, \"output\")\n+        assert \"...\" in result\n+\n+    def test_vision_analyze_normal_question(self):\n+        \"\"\"Normal question should be included.\"\"\"\n+        args = json.dumps({\"question\": \"What is this?\"})\n+        result = _summarize_tool_result(\"vision_analyze\", args, \"It's a cat\")\n+        assert \"vision_analyze\" in result\n+        assert \"What is this?\" in result\n+\n+    def test_vision_analyze_long_question_truncated(self):\n+        \"\"\"Long questions should be truncated.\"\"\"\n+        long_q = \"?\" * 100\n+        args = json.dumps({\"question\": long_q})\n+        result = _summarize_tool_result(\"vision_analyze\", args, \"answer\")\n+        assert len(result) < 150\n+\n+\n+class TestEdgeCases:\n+    \"\"\"Edge cases and boundary conditions.\"\"\"\n+\n+    def test_empty_args(self):\n+        \"\"\"Empty JSON object should not crash.\"\"\"\n+        result = _summarize_tool_result(\"terminal\", \"{}\", \"output\")\n+        assert \"terminal\" in result\n+\n+    def test_invalid_json(self):\n+        \"\"\"Invalid JSON should not crash.\"\"\"\n+        result = _summarize_tool_result(\"terminal\", \"not json\", \"output\")\n+        assert \"terminal\" in result\n+\n+    def test_null_args(self):\n+        \"\"\"None/null args should not crash.\"\"\"\n+        result = _summarize_tool_result(\"terminal\", None, \"output\")\n+        assert \"terminal\" in result\n+\n+    def test_non_dict_json_args(self):\n+        \"\"\"Args that parse to a non-dict (list/scalar) should not crash.\"\"\"\n+        result = _summarize_tool_result(\"terminal\", json.dumps([1, 2]), \"output\")\n+        assert \"terminal\" in result\n+        result = _summarize_tool_result(\"terminal\", json.dumps(\"bare\"), \"output\")\n+        assert \"terminal\" in result\n+\n+    def test_unknown_tool_name(self):\n+        \"\"\"Unknown tool name should return generic summary.\"\"\"\n+        args = json.dumps({\"foo\": \"bar\"})\n+        result = _summarize_tool_result(\"unknown_tool\", args, \"output\")\n+        # Should return some fallback, not crash\n+        assert isinstance(result, str)\n+\n+    def test_mixed_types_in_args(self):\n+        \"\"\"Args with mixed types should not crash.\"\"\"\n+        args = json.dumps({\n+            \"command\": \"ls\",\n+            \"background\": True,\n+            \"timeout\": 30,\n+            \"extra\": None\n+        })\n+        result = _summarize_tool_result(\"terminal\", args, '{\"exit_code\": 0}')\n+        assert \"terminal\" in result\n+        assert \"ls\" in result\n+\n+\n+class TestBackstopWrapper:\n+    \"\"\"The outer guard: NO input shape may raise out of _summarize_tool_result.\n+\n+    Compression retries on the same persisted history, so an escaping\n+    exception here becomes a crash loop. The wrapper returns a minimal\n+    '[tool] (N chars result)' summary when a branch fails.\n+    \"\"\"\n+\n+    def test_never_raises_matrix(self):\n+        \"\"\"Fuzz the per-tool branches with hostile value shapes.\"\"\"\n+        hostile_values = [None, True, 42, 3.14, [\"a\"], {\"k\": \"v\"}]\n+        tools = [\n+            \"terminal\", \"read_file\", \"write_file\", \"search_files\", \"patch\",\n+            \"browser_navigate\", \"web_search\", \"web_extract\", \"delegate_task\",\n+            \"execute_code\", \"skill_view\", \"vision_analyze\", \"memory\",\n+            \"cronjob\", \"process\", \"totally_unknown_tool\",\n+        ]\n+        keys = [\"command\", \"path\", \"content\", \"pattern\", \"url\", \"query\",\n+                \"urls\", \"goal\", \"code\", \"name\", \"question\", \"action\",\n+                \"target\", \"session_id\", \"mode\", \"offset\", \"ref\"]\n+        for tool in tools:\n+            for value in hostile_values:\n+                args = json.dumps({k: value for k in keys})\n+                result = _summarize_tool_result(tool, args, \"x\" * 250)\n+                assert isinstance(result, str) and result, (tool, value)\n+\n+    def test_backstop_fallback_shape(self):\n+        \"\"\"When a branch does fail, the fallback names the tool and size.\"\"\"\n+        from unittest.mock import patch as _patch\n+        with _patch(\n+            \"agent.context_compressor._summarize_tool_result_unguarded\",\n+            side_effect=TypeError(\"boom\"),\n+        ):\n+            result = _summarize_tool_result(\"terminal\", \"{}\", \"y\" * 300)\n+        assert result == \"[terminal] (300 chars result)\"\n+\n+    def test_backstop_handles_non_string_content(self):\n+        from unittest.mock import patch as _patch\n+        with _patch(\n+            \"agent.context_compressor._summarize_tool_result_unguarded\",\n+            side_effect=TypeError(\"boom\"),\n+        ):\n+            result = _summarize_tool_result(\"terminal\", \"{}\", None)\n+        assert result == \"[terminal] (0 chars result)\"\n+\n+\n+class TestDisplayPreviewTypeSafety:\n+    \"\"\"Sibling site: agent/display.py previews run on the live\n+    tool-progress callback and crashed on non-string process args.\"\"\"\n+\n+    def test_process_preview_non_string_session_id(self):\n+        from agent.display import build_tool_preview\n+        assert build_tool_preview(\"process\", {\"action\": \"poll\", \"session_id\": 123}) == \"poll 123\"\n+\n+    def test_process_preview_non_string_data(self):\n+        from agent.display import build_tool_preview\n+        result = build_tool_preview(\n+            \"process\", {\"action\": \"submit\", \"session_id\": \"abc\", \"data\": 42}\n+        )\n+        assert result == 'submit abc \"42\"'\n+\n+    def test_process_preview_none_action(self):\n+        from agent.display import build_tool_preview\n+        result = build_tool_preview(\"process\", {\"action\": None, \"session_id\": \"abc\"})\n+        assert isinstance(result, str)\n+\n+    def test_process_label_non_string_session_id(self):\n+        from agent.display import build_tool_label\n+        result = build_tool_label(\"process\", {\"action\": \"poll\", \"session_id\": 123})\n+        assert isinstance(result, str)\n"]}]},{"kind":"pr","number":85791,"title":"fix(agent): flatten list-typed delta.content/reasoning in auxiliary s…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"37fb7adfd6","number":0},{"kind":"commit","value":"3bc839508e","number":0},{"kind":"pr","value":"#104711","number":104711},{"kind":"pr","value":"#104994","number":104994},{"kind":"pr","value":"#104711","number":104711},{"kind":"pr","value":"#104848","number":104848},{"kind":"pr","value":"#104717","number":104717}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":94905,"title":"fix(agent): tolerate list-form delta.content in streaming (Mistral/GLM)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63755","number":63755},{"kind":"pr","value":"#63734","number":63734},{"kind":"pr","value":"#63755","number":63755},{"kind":"pr","value":"#63755","number":63755},{"kind":"pr","value":"#63755","number":63755},{"kind":"pr","value":"#94905","number":94905},{"kind":"pr","value":"#85791","number":85791}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":104994,"title":"fix: structured reasoning no longer breaks chat consumers (#104711)","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","agent/auxiliary_client.py","agent/reasoning_summaries.py","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","evals/providers/reasoning_shapes.py","tests/agent/test_reasoning_shape_boundaries.py","website/docs/developer-guide/provider-runtime.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}