Defensive coercion for JSON-string tool arguments from LLM

NousResearch/hermes-agent · cluster 14185 · agent

Data as of

Nothing open on GitHub. Fix merged in #22436. Check it covers your case.

The short answer

0open issues
0open PRs
14items grouped
Known?
Yes. 14 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #22436. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #22436, #52618. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #22436 fix(delegate): salvage #21933 JSON-string batch + diagnostic logging

    merged to main

    files: model_tools.py, tests/tools/test_delegate.py, tools/delegate_tool.py

    Show the diff
    • model_tools.py
      @@ -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",
      @@ -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(
      @@ -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
      @@ -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
      @@ -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,
      @@ -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(
      @@ -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

    files: tests/tools/test_todo_tool_type_coercion.py, tools/todo_tool.py

    Show the diff
    • tests/tools/test_todo_tool_type_coercion.py
      @@ -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
      @@ -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 = "?"
      @@ -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())]
      @@ -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()

9 PRs closed without merging

From the close comments: merged via another change 5 · superseded by another PR 3 · duplicate 1

  • #14187 fix(tools): defensive type coercion for todos in todo_tool · merged via another change → #52618, #14785
  • #14350 fix(todo): accept JSON-string todo lists · merged via another change → #52618, #14785
  • #14785 fix(tools): add defensive type coercion in todo_tool for malformed LLM input · merged via another change → #52618
  • #16952 fix: preserve tool-turn context and harden issue follow-ups · superseded by another PR → #52618, #14185, #14785
  • #21957 fix(delegate): normalize tasks-as-string in delegate_task for batch mode · superseded by another PR → #22436, #21966, #22092
Show 4 more
  • #21966 fix(delegate): accept JSON string batch tasks · merged via another change → 326ca75, 7330183, #22436, #21957, #22092
  • #22092 fix(delegate-task): recover batch tasks from JSON-encoded string coer… · superseded by another PR → #21966
  • #22505 fix(todo_tool): add defensive type coercion for LLM schema drift · merged via another change → #52618, #14785
  • #23073 fix(tools): defensive todo parsing when LLM emits JSON string (#14185) · duplicate → #14785, #14187, #14350, #22505, #14185
Closed issues · 3
  • Closed#14185todo_tool: AttributeError 'str' object has no attribute 'get' when LLM emits todos as JSON string
  • Closed#21933[Bug]: delegate_task batch mode fails silently when model emits tasks as JSON string
  • Closed#47940[Bug]: File creation/write problems with the Windows Version
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.13: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#22436, #52618
Closed issues
3
Attempts
9 PRs closed without merging
Fix link checked
25 Jun 2026

For your agent

The same facts, typed.