# NousResearch/hermes-agent · Defensive coercion for JSON-string tool arguments from LLM

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 14 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #22436. Verify it addresses your case.
- **Recommended:** read PR #22436 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-22436,pr-52618`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #14185: todo_tool: AttributeError 'str' object has no attribute 'get' when LLM emits todos as JSON string
- #21933: \[Bug\]: delegate_task batch mode fails silently when model emits tasks as JSON string
- #47940: \[Bug\]: File creation/write problems with the Windows Version

## Prior & competing PRs

- **#22092: fix(delegate-task): recover batch tasks from JSON-encoded string coer…**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#21966`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#22436: fix(delegate): salvage #21933 JSON-string batch + diagnostic logging**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `model_tools.py`, `tests/tools/test_delegate.py`, `tools/delegate_tool.py`
  - fix hunks:
  - `model_tools.py`

    ```diff
    @@ -550,6 +550,16 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
                         # nullable "null" → None).
                         args[key] = coerced
                         continue
    +                # If the string looks like a JSON array but _coerce_value
    +                # failed to parse it, warn clearly instead of silently wrapping.
    +                if value.strip().startswith("["):
    +                    logger.warning(
    +                        "coerce_tool_args: %s.%s looks like a JSON array string "
    +                        "but could not be parsed — model may have emitted a "
    +                        "JSON-encoded string instead of a native array. "
    +                        "Falling back to single-element list.",
    +                        tool_name, key,
    +                    )
                     args[key] = [value]
                     logger.info(
                         "coerce_tool_args: wrapped bare string in list for %s.%s",
    ```

    ```diff
    @@ -637,7 +647,12 @@ def _coerce_json(value: str, expected_python_type: type):
         """
         try:
             parsed = json.loads(value)
    -    except (ValueError, TypeError):
    +    except (ValueError, TypeError) as exc:
    +        logger.warning(
    +            "coerce_tool_args: failed to parse string as JSON for expected type %s: %s",
    +            expected_python_type.__name__,
    +            exc,
    +        )
             return value
         if isinstance(parsed, expected_python_type):
             logger.debug(
    ```

    ```diff
    @@ -645,6 +660,11 @@ def _coerce_json(value: str, expected_python_type: type):
                 expected_python_type.__name__,
             )
             return parsed
    +    logger.warning(
    +        "coerce_tool_args: JSON-parsed value is %s, expected %s — skipping coercion",
    +        type(parsed).__name__,
    +        expected_python_type.__name__,
    +    )
         return value
     
     
    ```
  - `tests/tools/test_delegate.py`

    ```diff
    @@ -167,6 +167,63 @@ def test_batch_mode(self, mock_run):
             self.assertEqual(result["results"][1]["summary"], "Result B")
             self.assertIn("total_duration_seconds", result)
     
    +    @patch("tools.delegate_tool._run_single_child")
    +    def test_batch_mode_accepts_json_string_tasks(self, mock_run):
    +        mock_run.side_effect = [
    +            {
    +                "task_index": 0,
    +                "status": "completed",
    +                "summary": "Result A",
    +                "api_calls": 2,
    +                "duration_seconds": 3.0,
    +            },
    +            {
    +                "task_index": 1,
    +                "status": "completed",
    +                "summary": "Result B",
    +                "api_calls": 4,
    +                "duration_seconds": 6.0,
    +            },
    +        ]
    +        parent = _make_mock_parent()
    +        tasks = json.dumps(
    +            [
    +                {"goal": "Research topic A"},
    +                {"goal": "Research topic B"},
    +            ]
    +        )
    +
    +        result = json.loads(delegate_task(tasks=tasks, parent_agent=parent))
    +
    +        self.assertIn("results", result)
    +        self.assertEqual(len(result["results"]), 2)
    +        self.assertEqual(result["results"][0]["summary"], "Result A")
    +        self.assertEqual(result["results"][1]["summary"], "Result B")
    +
    +    @patch("tools.delegate_tool._run_single_child")
    +    def test_batch_mode_rejects_non_object_tasks(self, mock_run):
    +        parent = _make_mock_parent()
    +
    +        result = json.loads(
    +            delegate_task(tasks=["not a task object"], parent_agent=parent)
    +        )
    +
    +        self.assertIn("error", result)
    +        self.assertIn("Task 0 must be an object", result["error"])
    +        mock_run.assert_not_called()
    +
    +    @patch("tools.delegate_tool._run_single_child")
    +    def test_batch_mode_rejects_malformed_json_string_tasks(self, mock_run):
    +        parent = _make_mock_parent()
    +
    +        result = json.loads(
    +            delegate_task(tasks='[{"goal": "bad}', parent_agent=parent)
    +        )
    +
    +        self.assertIn("error", result)
    +        self.assertIn("could not be parsed as JSON", result["error"])
    +        mock_run.assert_not_called()
    +
         @patch("tools.delegate_tool._run_single_child")
         def test_batch_capped_at_3(self, mock_run):
             mock_run.return_value = {
    ```
  - `tools/delegate_tool.py`

    ```diff
    @@ -1867,6 +1867,29 @@ def _run_with_thread_capture():
                 logger.debug("Failed to close child agent after delegation")
     
     
    +def _recover_tasks_from_json_string(
    +    tasks: Any,
    +) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
    +    if not isinstance(tasks, str):
    +        return None, None
    +    raw = tasks.strip()
    +    if not raw:
    +        return None, "Provide either 'goal' (single task) or 'tasks' (batch)."
    +    try:
    +        parsed = json.loads(raw)
    +    except json.JSONDecodeError as exc:
    +        return None, (
    +            "tasks must be a JSON array of task objects; received a string "
    +            f"that could not be parsed as JSON ({exc.msg})."
    +        )
    +    if not isinstance(parsed, list):
    +        return None, (
    +            f"tasks must be a JSON array of task objects; parsed "
    +            f"{type(parsed).__name__} instead."
    +        )
    +    return parsed, None
    +
    +
     def delegate_task(
         goal: Optional[str] = None,
         context: Optional[str] = None,
    ```

    ```diff
    @@ -1951,6 +1974,12 @@ def delegate_task(
     
         # Normalize to task list
         max_children = _get_max_concurrent_children()
    +    recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)
    +    if tasks_error:
    +        return tool_error(tasks_error)
    +    if recovered_tasks is not None:
    +        tasks = recovered_tasks
    +
         if tasks and isinstance(tasks, list):
             if len(tasks) > max_children:
                 return tool_error(
    ```

    ```diff
    @@ -1973,6 +2002,10 @@ def delegate_task(
     
         # Validate each task has a goal
         for i, task in enumerate(task_list):
    +        if not isinstance(task, dict):
    +            return tool_error(
    +                f"Task {i} must be an object, got {type(task).__name__}."
    +            )
             if not task.get("goal", "").strip():
                 return tool_error(f"Task {i} is missing a 'goal'.")
     
    ```
- **#52618: fix(tools): defensive type coercion in todo_tool for malformed LLM input (#14185)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tools/test_todo_tool_type_coercion.py`, `tools/todo_tool.py`
  - fix hunks:
  - `tests/tools/test_todo_tool_type_coercion.py`

    ```diff
    @@ -0,0 +1,133 @@
    +"""Tests for defensive type coercion in todo_tool (issue #14185).
    +
    +Covers three crash patterns:
    +1. todos is a JSON string instead of a list
    +2. todos list contains non-dict items (e.g., bare strings)
    +3. Well-formed input continues to work unchanged
    +"""
    +
    +import json
    +
    +from tools.todo_tool import TodoStore, todo_tool
    +
    +
    +class TestJsonStringCoercion:
    +    """Guard 1: todo_tool() recovers when LLM sends todos as a JSON string."""
    +
    +    def test_json_string_is_parsed_into_list(self):
    +        store = TodoStore()
    +        todos_str = json.dumps([
    +            {"id": "t1", "content": "Do A", "status": "pending"},
    +            {"id": "t2", "content": "Do B", "status": "in_progress"},
    +        ])
    +        result = json.loads(todo_tool(todos=todos_str, store=store))
    +        assert "error" not in result
    +        assert result["summary"]["total"] == 2
    +        assert result["todos"][0]["id"] == "t1"
    +        assert result["todos"][1]["status"] == "in_progress"
    +
    +    def test_unparseable_string_returns_error(self):
    +        store = TodoStore()
    +        result = json.loads(todo_tool(todos="not valid json [", store=store))
    +        assert "error" in result
    +
    +    def test_json_string_that_parses_to_non_list_returns_error(self):
    +        store = TodoStore()
    +        # Valid JSON, but a dict instead of a list
    +        result = json.loads(todo_tool(todos='{"id": "1"}', store=store))
    +        assert "error" in result
    +
    +    def test_non_list_non_string_returns_error(self):
    +        store = TodoStore()
    +        result = json.loads(todo_tool(todos=42, store=store))
    +        assert "error" in result
    +
    +
    +class TestNonDictListItems:
    +    """Guards 2 & 3: _validate and _dedupe_by_id handle non-dict items."""
    +
    +    def test_string_item_in_list_does_not_crash(self):
    +        store = TodoStore()
    +        result = store.write(["not-a-dict"])
    +        assert len(result) == 1
    +        assert result[0]["id"] == "?"
    +        assert result[0]["content"] == "(invalid item)"
    +        assert result[0]["status"] == "pending"
    +
    +    def test_mixed_valid_and_invalid_items(self):
    +        store = TodoStore()
    +        result = store.write([
    +            {"id": "1", "content": "Real task", "status": "pending"},
    +            "garbage",
    +            42,
    +            {"id": "2", "content": "Another task", "status": "completed"},
    +        ])
    +        assert len(result) == 4
    +        # Valid items are preserved
    +        assert result[0]["id"] == "1"
    +        assert result[0]["content"] == "Real task"
    +        assert result[3]["id"] == "2"
    +        # Invalid items get placeholder values
    +        assert result[1]["content"] == "(invalid item)"
    +        assert result[2]["content"] == "(invalid item)"
    +
    +    def test_none_item_in_list(self):
    +        store = TodoStore()
    +        result = store.write([None])
    +        assert len(result) == 1
    +        assert result[0]["id"] == "?"
    +
    +    def test_integer_item_in_list(self):
    +        store = TodoStore()
    +        result = store.write([123])
    +        assert len(result) == 1
    +        assert result[0]["content"] == "(invalid item)"
    +
    +    def test_non_dict_items_via_todo_tool(self):
    +        """End-to-end: non-dict list items produce valid output, not a crash."""
    +        store = TodoStore()
    +        result = json.loads(todo_tool(todos=["bad", "also bad"], store=store))
    +        assert "error" not in result
    +        assert result["summary"]["total"] == 2
    +        assert result["summary"]["pending"] == 2
    +
    +
    +class TestWellFormedInputUnchanged:
    +    """Regression: normal usage must not be affected by the guards."""
    +
    +    def test_normal_write_and_read(self):
    +        store = TodoStore()
    +        items = [
    +            {"id": "a", "content": "First", "status": "pending"},
    +            {"id": "b", "content": "Second", "status": "in_progress"},
    +        ]
    +        result = json.loads(todo_tool(todos=items, store=store))
    +        assert result["summary"]["total"] == 2
    +        assert result["summary"]["pending"] == 1
    +        assert result["summary"]["in_progress"] == 1
    +
    +    def test_merge_mode_still_works(self):
    +        store = TodoStore()
    +        store.write([{"id": "1", "content": "Original", "status": "pending"}])
    +        result = json.loads(todo_tool(
    +            todos=[{"id": "1", "status": "completed"}],
    +            merge=True,
    +            store=store,
    +        ))
    +        assert result["summary"]["completed"] == 1
    +        assert result["todos"][0]["content"] == "Original"
    +
    +    def test_read_mode_still_works(self):
    +        store = TodoStore()
    +        store.write([{"id": "x", "content": "Task", "status": "pending"}])
    +        result = json.loads(todo_tool(store=store))
    +        assert result["summary"]["total"] == 1
    +
    +    def test_dedup_still_works(self):
    +        store = TodoStore()
    +        result = store.write([
    +            {"id": "1", "content": "v1", "status": "pending"},
    +            {"id": "1", "content": "v2", "status": "in_progress"},
    +        ])
    +        assert len(result) == 1
    +        assert result[0]["content"] == "v2"
    ```
  - `tools/todo_tool.py`

    ```diff
    @@ -158,6 +158,9 @@ def _validate(item: Dict[str, Any]) -> Dict[str, str]:
             Ensures required fields exist and status is valid.
             Returns a clean dict with only {id, content, status}.
             """
    +        if not isinstance(item, dict):
    +            return {"id": "?", "content": "(invalid item)", "status": "pending"}
    +
             item_id = str(item.get("id", "")).strip()
             if not item_id:
                 item_id = "?"
    ```

    ```diff
    @@ -179,6 +182,10 @@ def _dedupe_by_id(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
             """Collapse duplicate ids, keeping the last occurrence in its position."""
             last_index: Dict[str, int] = {}
             for i, item in enumerate(todos):
    +            if not isinstance(item, dict):
    +                # Non-dict items get a synthetic key so _validate can handle them
    +                last_index[f"__invalid_{i}"] = i
    +                continue
                 item_id = str(item.get("id", "")).strip() or "?"
                 last_index[item_id] = i
             return [todos[i] for i in sorted(last_index.values())]
    ```

    ```diff
    @@ -204,6 +211,16 @@ def todo_tool(
             return tool_error("TodoStore not initialized")
     
         if todos is not None:
    +        # Guard: LLM sometimes sends todos as a JSON string instead of a list
    +        if isinstance(todos, str):
    +            try:
    +                todos = json.loads(todos)
    +            except (json.JSONDecodeError, TypeError):
    +                return tool_error("todos must be a list of objects, got unparseable string")
    +        if not isinstance(todos, list):
    +            return tool_error(
    +                f"todos must be a list, got {type(todos).__name__}"
    +            )
             items = store.write(todos, merge)
         else:
             items = store.read()
    ```
- **#14187: fix(tools): defensive type coercion for todos in todo_tool**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52618`, `#14785`, `#52618`
- **#14350: fix(todo): accept JSON-string todo lists**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52618`, `#14785`, `#52618`
- **#14785: fix(tools): add defensive type coercion in todo_tool for malformed LLM input**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52618`
- **#16952: fix: preserve tool-turn context and harden issue follow-ups**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#52618`, `#14185`, `#14785`
- **#21957: fix(delegate): normalize tasks-as-string in delegate_task for batch mode**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#22436`, `#21966`, `#22092`
- **#21966: fix(delegate): accept JSON string batch tasks**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `326ca754a`, `7330183d0`, `#22436`, `#21957`, `#22092`
- **#22505: fix(todo_tool): add defensive type coercion for LLM schema drift**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52618`, `#14785`, `#52618`
- **#23073: fix(tools): defensive todo parsing when LLM emits JSON string (#14185)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#14785`, `#14187`, `#14350`, `#22505`, `#14185`

## Fix status

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