# NousResearch/hermes-agent · Handling null tool-call arguments without crashing

## Open now

- Nothing open on GitHub.

These issues and PRs address a bug where null (non-string) tool-call function arguments from providers cause unhandled TypeErrors that crash the conversation turn. Fixes span agent logic and display layers to tolerate null values gracefully.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #64634. Verify it addresses your case.
- **Recommended:** read PR #64634 (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: `resolved` · deciding PR(s): `pr-64634`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: 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

- #50892: Unhandled \`TypeError\` crashes the whole conversation turn when a provider returns \`tool_call.function.arguments\` as \`null\` (non-string)

## Prior & competing PRs

- **#64634: fix: null tool-call arguments no longer crash the conversation turn**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/conversation_loop.py`, `agent/tool_dispatch_helpers.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `agent/conversation_loop.py`

    ```diff
    @@ -4495,7 +4495,9 @@ def _perform_api_call(next_api_kwargs):
                     
                     if agent.verbose_logging:
                         for tc in assistant_message.tool_calls:
    -                        logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...")
    +                        raw_args = tc.function.arguments
    +                        args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
    +                        logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
                     
                     # Validate tool call names - detect model hallucinations
                     # Repair mismatched tool names before validating
    ```
  - `agent/tool_dispatch_helpers.py`

    ```diff
    @@ -155,10 +155,11 @@ def _add_sequential(tc) -> None:
             try:
                 function_args = json.loads(tool_call.function.arguments)
             except Exception:
    +            _raw = tool_call.function.arguments
                 logging.debug(
                     "Could not parse args for %s — treating as sequential barrier; raw=%s",
                     tool_name,
    -                tool_call.function.arguments[:200],
    +                _raw[:200] if isinstance(_raw, str) else repr(_raw)[:200],
                 )
                 _add_sequential(tool_call)
                 continue
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -2443,6 +2443,24 @@ def test_invalid_json_args_are_rejected_without_dispatch(self, agent):
             assert "valid json object" in messages[0]["content"].lower()
             assert "tool was not executed" in messages[0]["content"].lower()
     
    +    def test_none_args_rejected_without_dispatch(self, agent):
    +        """None arguments must not crash the dispatch path. Current contract:
    +        malformed (non-string, non-JSON-object) args are rejected without
    +        executing the tool — same as invalid JSON strings. The mainline
    +        run_conversation path normalizes None to "{}" BEFORE dispatch (see
    +        test_tool_call_none_args_verbose_logging_does_not_crash), so this
    +        direct-dispatch path only needs to degrade gracefully, not coerce."""
    +        tc = _mock_tool_call(name="web_search", arguments=None, call_id="c1")
    +        mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
    +        messages = []
    +        with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc:
    +            agent._execute_tool_calls(mock_msg, messages, "task-1")
    +            mock_hfc.assert_not_called()
    +        assert len(messages) == 1
    +        assert messages[0]["role"] == "tool"
    +        assert messages[0]["tool_call_id"] == "c1"
    +        assert "tool was not executed" in messages[0]["content"].lower()
    +
         def test_result_truncation_over_100k(self, agent, tmp_path, monkeypatch):
             monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
             (tmp_path / ".hermes").mkdir()
    ```

    ```diff
    @@ -2707,6 +2725,20 @@ def test_malformed_json_args_forces_sequential(self, agent):
                     mock_seq.assert_called_once()
                     mock_con.assert_not_called()
     
    +    def test_none_args_batch_does_not_crash_parallelism_gating(self, agent):
    +        """Non-string tool arguments must not crash the segment planner —
    +        the None-args call becomes a sequential barrier and the batch
    +        dispatches without raising."""
    +        tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
    +        tc2 = _mock_tool_call(name="web_search", arguments=None, call_id="c2")
    +        mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
    +        messages = []
    +        with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq:
    +            with patch.object(agent, "_execute_tool_calls_concurrent") as mock_con:
    +                agent._execute_tool_calls(mock_msg, messages, "task-1")
    +                mock_seq.assert_called_once()
    +                mock_con.assert_not_called()
    +
         def test_non_dict_args_forces_sequential(self, agent):
             """Tool arguments that parse to a non-dict type should fall back to sequential."""
             tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
    ```

    ```diff
    @@ -2719,6 +2751,22 @@ def test_non_dict_args_forces_sequential(self, agent):
                     mock_seq.assert_called_once()
                     mock_con.assert_not_called()
     
    +    def test_dict_args_batch_forces_sequential_without_crash(self, agent):
    +        """Pre-parsed dict arguments (non-string) must not crash the planner.
    +        Current contract: the mainline loop normalizes dict args to JSON
    +        strings BEFORE dispatch, so raw dicts reaching the gate are treated
    +        as barriers (defensive sequential), consistent with the executors
    +        rejecting non-string args rather than repairing them."""
    +        tc1 = _mock_tool_call(name="web_search", arguments={"q": "alpha"}, call_id="c1")
    +        tc2 = _mock_tool_call(name="web_search", arguments={"q": "beta"}, call_id="c2")
    +        mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
    +        messages = []
    +        with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq:
    +            with patch.object(agent, "_execute_tool_calls_concurrent") as mock_con:
    +                agent._execute_tool_calls(mock_msg, messages, "task-1")
    +                mock_seq.assert_called_once()
    +                mock_con.assert_not_called()
    +
         def test_concurrent_executes_all_tools(self, agent):
             """Concurrent path should execute all tools and append results in order."""
             tc1 = _mock_tool_call(name="web_search", arguments='{"q":"alpha"}', call_id="c1")
    ```

    ```diff
    @@ -2748,6 +2796,29 @@ def fake_handle(name, args, task_id, **kwargs):
             assert "beta" in messages[1]["content"]
             assert "gamma" in messages[2]["content"]
     
    +    def test_concurrent_none_args_rejected_without_crash(self, agent):
    +        """Concurrent executor must not crash on arguments=None. Current
    +        contract (_parse_tool_arguments): non-object args are rejected with
    +        a structured error result and the tool is not executed; the valid
    +        sibling still runs. One result per call, in order."""
    +        tc1 = _mock_tool_call(name="web_search", arguments=None, call_id="c1")
    +        tc2 = _mock_tool_call(name="web_search", arguments='{"q":"ok"}', call_id="c2")
    +        mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
    +        messages = []
    +        seen_args = []
    +
    +        def fake_handle(name, args, task_id, **kwargs):
    +            seen_args.append((kwargs["tool_call_id"], args))
    +            return "ok"
    +
    +        with patch("run_agent.handle_function_call", side_effect=fake_handle):
    +            agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
    +
    +        # Only the valid call executed; the None-args call was rejected.
    +        assert seen_args == [("c2", {"q": "ok"})]
    +        assert [m["tool_call_id"] for m in messages] == ["c1", "c2"]
    +        assert "tool was not executed" in messages[0]["content"].lower()
    +
         def test_concurrent_preserves_order_despite_timing(self, agent):
             """Even if tools finish in different order, messages should be in original order."""
             import time as _time
    ```

    ```diff
    @@ -4121,6 +4192,25 @@ def test_tool_calls_then_stop(self, agent):
             assert mock_handle_function_call.call_args.kwargs["tool_call_id"] == "c1"
             assert mock_handle_function_call.call_args.kwargs["session_id"] == agent.session_id
     
    +    def test_tool_call_none_args_verbose_logging_does_not_crash(self, agent):
    +        self._setup_agent(agent)
    +        agent.verbose_logging = True
    +        tc = _mock_tool_call(name="web_search", arguments=None, call_id="c1")
    +        resp1 = _mock_response(content="", finish_reason="tool_calls", tool_calls=[tc])
    +        resp2 = _mock_response(content="Done searching", finish_reason="stop")
    +        agent.client.chat.completions.create.side_effect = [resp1, resp2]
    +
    +        with (
    +            patch("run_agent.handle_function_call", return_value="search result") as mock_handle_function_call,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            result = agent.run_conversation("search something")
    +
    +        assert result["final_response"] == "Done searching"
    +        assert mock_handle_function_call.call_args.args[:2] == ("web_search", {})
    +
         def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent):
             self._setup_agent(agent)
             tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
    ```
- **#50896: fix: handle null tool call arguments**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `94456a128`, `#64634`, `#50892`, `#64460`
- **#52033: fix(agent): tolerate null tool-call arguments**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#50892`, `#50896`, `#64634`

## Fix status

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