{"repo":"NousResearch/hermes-agent","public_id":"14185","display_label":"Defensive coercion for JSON-string tool arguments from LLM","summary":"Most members address a recurring pattern where LLMs emit tool arguments (todo lists, delegate_task batch tasks) as JSON-encoded strings instead of parsed objects, causing errors. Fixes add defensive type coercion and normalization across todo_tool, delegate_task, and MCP tool argument handling.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22436},"resolution":{"state":"partially_resolved","by":"pr-22436,pr-52618"},"freshness":{"state":"verified","last_verified_at":"2026-06-25T20:32:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12879120879120878},"fix_edges":[{"issue_number":14185,"pr_number":52618,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21933,"pr_number":22436,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":14185,"title":"todo_tool: AttributeError 'str' object has no attribute 'get' when LLM emits todos as JSON string","url":null,"state":"closed"},{"kind":"issue","number":21933,"title":"[Bug]: delegate_task batch mode fails silently when model emits tasks as JSON string","url":null,"state":"closed"},{"kind":"issue","number":47940,"title":"[Bug]: File creation/write problems with the Windows Version","url":null,"state":"closed"},{"kind":"pr","number":14187,"title":"fix(tools): defensive type coercion for todos in todo_tool","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52618","number":52618},{"kind":"pr","value":"#14785","number":14785},{"kind":"pr","value":"#52618","number":52618}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14350,"title":"fix(todo): accept JSON-string todo lists","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52618","number":52618},{"kind":"pr","value":"#14785","number":14785},{"kind":"pr","value":"#52618","number":52618}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14785,"title":"fix(tools): add defensive type coercion in todo_tool for malformed LLM input","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52618","number":52618}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16952,"title":"fix: preserve tool-turn context and harden issue follow-ups","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52618","number":52618},{"kind":"pr","value":"#14185","number":14185},{"kind":"pr","value":"#14785","number":14785}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":21957,"title":"fix(delegate): normalize tasks-as-string in delegate_task for batch mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22436","number":22436},{"kind":"pr","value":"#21966","number":21966},{"kind":"pr","value":"#22092","number":22092}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":21966,"title":"fix(delegate): accept JSON string batch tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"326ca754a","number":0},{"kind":"commit","value":"7330183d0","number":0},{"kind":"pr","value":"#22436","number":22436},{"kind":"pr","value":"#21957","number":21957},{"kind":"pr","value":"#22092","number":22092}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22092,"title":"fix(delegate-task): recover batch tasks from JSON-encoded string coer…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21966","number":21966}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22436,"title":"fix(delegate): salvage #21933 JSON-string batch + diagnostic logging","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["model_tools.py","tests/tools/test_delegate.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"model_tools.py","hunks":["@@ -550,6 +550,16 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:\n                     # nullable \"null\" → None).\n                     args[key] = coerced\n                     continue\n+                # If the string looks like a JSON array but _coerce_value\n+                # failed to parse it, warn clearly instead of silently wrapping.\n+                if value.strip().startswith(\"[\"):\n+                    logger.warning(\n+                        \"coerce_tool_args: %s.%s looks like a JSON array string \"\n+                        \"but could not be parsed — model may have emitted a \"\n+                        \"JSON-encoded string instead of a native array. \"\n+                        \"Falling back to single-element list.\",\n+                        tool_name, key,\n+                    )\n                 args[key] = [value]\n                 logger.info(\n                     \"coerce_tool_args: wrapped bare string in list for %s.%s\",","@@ -637,7 +647,12 @@ def _coerce_json(value: str, expected_python_type: type):\n     \"\"\"\n     try:\n         parsed = json.loads(value)\n-    except (ValueError, TypeError):\n+    except (ValueError, TypeError) as exc:\n+        logger.warning(\n+            \"coerce_tool_args: failed to parse string as JSON for expected type %s: %s\",\n+            expected_python_type.__name__,\n+            exc,\n+        )\n         return value\n     if isinstance(parsed, expected_python_type):\n         logger.debug(","@@ -645,6 +660,11 @@ def _coerce_json(value: str, expected_python_type: type):\n             expected_python_type.__name__,\n         )\n         return parsed\n+    logger.warning(\n+        \"coerce_tool_args: JSON-parsed value is %s, expected %s — skipping coercion\",\n+        type(parsed).__name__,\n+        expected_python_type.__name__,\n+    )\n     return value\n \n "]},{"file":"tests/tools/test_delegate.py","hunks":["@@ -167,6 +167,63 @@ def test_batch_mode(self, mock_run):\n         self.assertEqual(result[\"results\"][1][\"summary\"], \"Result B\")\n         self.assertIn(\"total_duration_seconds\", result)\n \n+    @patch(\"tools.delegate_tool._run_single_child\")\n+    def test_batch_mode_accepts_json_string_tasks(self, mock_run):\n+        mock_run.side_effect = [\n+            {\n+                \"task_index\": 0,\n+                \"status\": \"completed\",\n+                \"summary\": \"Result A\",\n+                \"api_calls\": 2,\n+                \"duration_seconds\": 3.0,\n+            },\n+            {\n+                \"task_index\": 1,\n+                \"status\": \"completed\",\n+                \"summary\": \"Result B\",\n+                \"api_calls\": 4,\n+                \"duration_seconds\": 6.0,\n+            },\n+        ]\n+        parent = _make_mock_parent()\n+        tasks = json.dumps(\n+            [\n+                {\"goal\": \"Research topic A\"},\n+                {\"goal\": \"Research topic B\"},\n+            ]\n+        )\n+\n+        result = json.loads(delegate_task(tasks=tasks, parent_agent=parent))\n+\n+        self.assertIn(\"results\", result)\n+        self.assertEqual(len(result[\"results\"]), 2)\n+        self.assertEqual(result[\"results\"][0][\"summary\"], \"Result A\")\n+        self.assertEqual(result[\"results\"][1][\"summary\"], \"Result B\")\n+\n+    @patch(\"tools.delegate_tool._run_single_child\")\n+    def test_batch_mode_rejects_non_object_tasks(self, mock_run):\n+        parent = _make_mock_parent()\n+\n+        result = json.loads(\n+            delegate_task(tasks=[\"not a task object\"], parent_agent=parent)\n+        )\n+\n+        self.assertIn(\"error\", result)\n+        self.assertIn(\"Task 0 must be an object\", result[\"error\"])\n+        mock_run.assert_not_called()\n+\n+    @patch(\"tools.delegate_tool._run_single_child\")\n+    def test_batch_mode_rejects_malformed_json_string_tasks(self, mock_run):\n+        parent = _make_mock_parent()\n+\n+        result = json.loads(\n+            delegate_task(tasks='[{\"goal\": \"bad}', parent_agent=parent)\n+        )\n+\n+        self.assertIn(\"error\", result)\n+        self.assertIn(\"could not be parsed as JSON\", result[\"error\"])\n+        mock_run.assert_not_called()\n+\n     @patch(\"tools.delegate_tool._run_single_child\")\n     def test_batch_capped_at_3(self, mock_run):\n         mock_run.return_value = {"]},{"file":"tools/delegate_tool.py","hunks":["@@ -1867,6 +1867,29 @@ def _run_with_thread_capture():\n             logger.debug(\"Failed to close child agent after delegation\")\n \n \n+def _recover_tasks_from_json_string(\n+    tasks: Any,\n+) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:\n+    if not isinstance(tasks, str):\n+        return None, None\n+    raw = tasks.strip()\n+    if not raw:\n+        return None, \"Provide either 'goal' (single task) or 'tasks' (batch).\"\n+    try:\n+        parsed = json.loads(raw)\n+    except json.JSONDecodeError as exc:\n+        return None, (\n+            \"tasks must be a JSON array of task objects; received a string \"\n+            f\"that could not be parsed as JSON ({exc.msg}).\"\n+        )\n+    if not isinstance(parsed, list):\n+        return None, (\n+            f\"tasks must be a JSON array of task objects; parsed \"\n+            f\"{type(parsed).__name__} instead.\"\n+        )\n+    return parsed, None\n+\n+\n def delegate_task(\n     goal: Optional[str] = None,\n     context: Optional[str] = None,","@@ -1951,6 +1974,12 @@ def delegate_task(\n \n     # Normalize to task list\n     max_children = _get_max_concurrent_children()\n+    recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)\n+    if tasks_error:\n+        return tool_error(tasks_error)\n+    if recovered_tasks is not None:\n+        tasks = recovered_tasks\n+\n     if tasks and isinstance(tasks, list):\n         if len(tasks) > max_children:\n             return tool_error(","@@ -1973,6 +2002,10 @@ def delegate_task(\n \n     # Validate each task has a goal\n     for i, task in enumerate(task_list):\n+        if not isinstance(task, dict):\n+            return tool_error(\n+                f\"Task {i} must be an object, got {type(task).__name__}.\"\n+            )\n         if not task.get(\"goal\", \"\").strip():\n             return tool_error(f\"Task {i} is missing a 'goal'.\")\n \n"]}]},{"kind":"pr","number":22505,"title":"fix(todo_tool): add defensive type coercion for LLM schema drift","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52618","number":52618},{"kind":"pr","value":"#14785","number":14785},{"kind":"pr","value":"#52618","number":52618}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23073,"title":"fix(tools): defensive todo parsing when LLM emits JSON string (#14185)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14785","number":14785},{"kind":"pr","value":"#14187","number":14187},{"kind":"pr","value":"#14350","number":14350},{"kind":"pr","value":"#22505","number":22505},{"kind":"pr","value":"#14185","number":14185}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":52618,"title":"fix(tools): defensive type coercion in todo_tool for malformed LLM input (#14185)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_todo_tool_type_coercion.py","tools/todo_tool.py"],"fix_hunks":[{"file":"tests/tools/test_todo_tool_type_coercion.py","hunks":["@@ -0,0 +1,133 @@\n+\"\"\"Tests for defensive type coercion in todo_tool (issue #14185).\n+\n+Covers three crash patterns:\n+1. todos is a JSON string instead of a list\n+2. todos list contains non-dict items (e.g., bare strings)\n+3. Well-formed input continues to work unchanged\n+\"\"\"\n+\n+import json\n+\n+from tools.todo_tool import TodoStore, todo_tool\n+\n+\n+class TestJsonStringCoercion:\n+    \"\"\"Guard 1: todo_tool() recovers when LLM sends todos as a JSON string.\"\"\"\n+\n+    def test_json_string_is_parsed_into_list(self):\n+        store = TodoStore()\n+        todos_str = json.dumps([\n+            {\"id\": \"t1\", \"content\": \"Do A\", \"status\": \"pending\"},\n+            {\"id\": \"t2\", \"content\": \"Do B\", \"status\": \"in_progress\"},\n+        ])\n+        result = json.loads(todo_tool(todos=todos_str, store=store))\n+        assert \"error\" not in result\n+        assert result[\"summary\"][\"total\"] == 2\n+        assert result[\"todos\"][0][\"id\"] == \"t1\"\n+        assert result[\"todos\"][1][\"status\"] == \"in_progress\"\n+\n+    def test_unparseable_string_returns_error(self):\n+        store = TodoStore()\n+        result = json.loads(todo_tool(todos=\"not valid json [\", store=store))\n+        assert \"error\" in result\n+\n+    def test_json_string_that_parses_to_non_list_returns_error(self):\n+        store = TodoStore()\n+        # Valid JSON, but a dict instead of a list\n+        result = json.loads(todo_tool(todos='{\"id\": \"1\"}', store=store))\n+        assert \"error\" in result\n+\n+    def test_non_list_non_string_returns_error(self):\n+        store = TodoStore()\n+        result = json.loads(todo_tool(todos=42, store=store))\n+        assert \"error\" in result\n+\n+\n+class TestNonDictListItems:\n+    \"\"\"Guards 2 & 3: _validate and _dedupe_by_id handle non-dict items.\"\"\"\n+\n+    def test_string_item_in_list_does_not_crash(self):\n+        store = TodoStore()\n+        result = store.write([\"not-a-dict\"])\n+        assert len(result) == 1\n+        assert result[0][\"id\"] == \"?\"\n+        assert result[0][\"content\"] == \"(invalid item)\"\n+        assert result[0][\"status\"] == \"pending\"\n+\n+    def test_mixed_valid_and_invalid_items(self):\n+        store = TodoStore()\n+        result = store.write([\n+            {\"id\": \"1\", \"content\": \"Real task\", \"status\": \"pending\"},\n+            \"garbage\",\n+            42,\n+            {\"id\": \"2\", \"content\": \"Another task\", \"status\": \"completed\"},\n+        ])\n+        assert len(result) == 4\n+        # Valid items are preserved\n+        assert result[0][\"id\"] == \"1\"\n+        assert result[0][\"content\"] == \"Real task\"\n+        assert result[3][\"id\"] == \"2\"\n+        # Invalid items get placeholder values\n+        assert result[1][\"content\"] == \"(invalid item)\"\n+        assert result[2][\"content\"] == \"(invalid item)\"\n+\n+    def test_none_item_in_list(self):\n+        store = TodoStore()\n+        result = store.write([None])\n+        assert len(result) == 1\n+        assert result[0][\"id\"] == \"?\"\n+\n+    def test_integer_item_in_list(self):\n+        store = TodoStore()\n+        result = store.write([123])\n+        assert len(result) == 1\n+        assert result[0][\"content\"] == \"(invalid item)\"\n+\n+    def test_non_dict_items_via_todo_tool(self):\n+        \"\"\"End-to-end: non-dict list items produce valid output, not a crash.\"\"\"\n+        store = TodoStore()\n+        result = json.loads(todo_tool(todos=[\"bad\", \"also bad\"], store=store))\n+        assert \"error\" not in result\n+        assert result[\"summary\"][\"total\"] == 2\n+        assert result[\"summary\"][\"pending\"] == 2\n+\n+\n+class TestWellFormedInputUnchanged:\n+    \"\"\"Regression: normal usage must not be affected by the guards.\"\"\"\n+\n+    def test_normal_write_and_read(self):\n+        store = TodoStore()\n+        items = [\n+            {\"id\": \"a\", \"content\": \"First\", \"status\": \"pending\"},\n+            {\"id\": \"b\", \"content\": \"Second\", \"status\": \"in_progress\"},\n+        ]\n+        result = json.loads(todo_tool(todos=items, store=store))\n+        assert result[\"summary\"][\"total\"] == 2\n+        assert result[\"summary\"][\"pending\"] == 1\n+        assert result[\"summary\"][\"in_progress\"] == 1\n+\n+    def test_merge_mode_still_works(self):\n+        store = TodoStore()\n+        store.write([{\"id\": \"1\", \"content\": \"Original\", \"status\": \"pending\"}])\n+        result = json.loads(todo_tool(\n+            todos=[{\"id\": \"1\", \"status\": \"completed\"}],\n+            merge=True,\n+            store=store,\n+        ))\n+        assert result[\"summary\"][\"completed\"] == 1\n+        assert result[\"todos\"][0][\"content\"] == \"Original\"\n+\n+    def test_read_mode_still_works(self):\n+        store = TodoStore()\n+        store.write([{\"id\": \"x\", \"content\": \"Task\", \"status\": \"pending\"}])\n+        result = json.loads(todo_tool(store=store))\n+        assert result[\"summary\"][\"total\"] == 1\n+\n+    def test_dedup_still_works(self):\n+        store = TodoStore()\n+        result = store.write([\n+            {\"id\": \"1\", \"content\": \"v1\", \"status\": \"pending\"},\n+            {\"id\": \"1\", \"content\": \"v2\", \"status\": \"in_progress\"},\n+        ])\n+        assert len(result) == 1\n+        assert result[0][\"content\"] == \"v2\""]},{"file":"tools/todo_tool.py","hunks":["@@ -158,6 +158,9 @@ def _validate(item: Dict[str, Any]) -> Dict[str, str]:\n         Ensures required fields exist and status is valid.\n         Returns a clean dict with only {id, content, status}.\n         \"\"\"\n+        if not isinstance(item, dict):\n+            return {\"id\": \"?\", \"content\": \"(invalid item)\", \"status\": \"pending\"}\n+\n         item_id = str(item.get(\"id\", \"\")).strip()\n         if not item_id:\n             item_id = \"?\"","@@ -179,6 +182,10 @@ def _dedupe_by_id(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n         \"\"\"Collapse duplicate ids, keeping the last occurrence in its position.\"\"\"\n         last_index: Dict[str, int] = {}\n         for i, item in enumerate(todos):\n+            if not isinstance(item, dict):\n+                # Non-dict items get a synthetic key so _validate can handle them\n+                last_index[f\"__invalid_{i}\"] = i\n+                continue\n             item_id = str(item.get(\"id\", \"\")).strip() or \"?\"\n             last_index[item_id] = i\n         return [todos[i] for i in sorted(last_index.values())]","@@ -204,6 +211,16 @@ def todo_tool(\n         return tool_error(\"TodoStore not initialized\")\n \n     if todos is not None:\n+        # Guard: LLM sometimes sends todos as a JSON string instead of a list\n+        if isinstance(todos, str):\n+            try:\n+                todos = json.loads(todos)\n+            except (json.JSONDecodeError, TypeError):\n+                return tool_error(\"todos must be a list of objects, got unparseable string\")\n+        if not isinstance(todos, list):\n+            return tool_error(\n+                f\"todos must be a list, got {type(todos).__name__}\"\n+            )\n         items = store.write(todos, merge)\n     else:\n         items = store.read()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}