{"repo":"NousResearch/hermes-agent","public_id":"50892","display_label":"Handling null tool-call arguments without crashing","summary":"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.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64634,"merged_at":"2026-07-15T04:46:41+00:00"},"resolution":{"state":"resolved","by":"pr-64634"},"freshness":{"state":"verified","last_verified_at":"2026-07-15T04:46:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":50892,"pr_number":64634,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-15T04:46:41+00:00"}],"members":[{"kind":"issue","number":50892,"title":"Unhandled `TypeError` crashes the whole conversation turn when a provider returns `tool_call.function.arguments` as `null` (non-string)","url":null,"state":"closed"},{"kind":"pr","number":50896,"title":"fix: handle null tool call arguments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"94456a128","number":0},{"kind":"pr","value":"#64634","number":64634},{"kind":"pr","value":"#50892","number":50892},{"kind":"pr","value":"#64460","number":64460}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52033,"title":"fix(agent): tolerate null tool-call arguments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50892","number":50892},{"kind":"pr","value":"#50896","number":50896},{"kind":"pr","value":"#64634","number":64634}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64634,"title":"fix: null tool-call arguments no longer crash the conversation turn","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","agent/tool_dispatch_helpers.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -4495,7 +4495,9 @@ def _perform_api_call(next_api_kwargs):\n                 \n                 if agent.verbose_logging:\n                     for tc in assistant_message.tool_calls:\n-                        logging.debug(f\"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...\")\n+                        raw_args = tc.function.arguments\n+                        args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]\n+                        logging.debug(\"Tool call: %s with args: %s...\", tc.function.name, args_preview)\n                 \n                 # Validate tool call names - detect model hallucinations\n                 # Repair mismatched tool names before validating"]},{"file":"agent/tool_dispatch_helpers.py","hunks":["@@ -155,10 +155,11 @@ def _add_sequential(tc) -> None:\n         try:\n             function_args = json.loads(tool_call.function.arguments)\n         except Exception:\n+            _raw = tool_call.function.arguments\n             logging.debug(\n                 \"Could not parse args for %s — treating as sequential barrier; raw=%s\",\n                 tool_name,\n-                tool_call.function.arguments[:200],\n+                _raw[:200] if isinstance(_raw, str) else repr(_raw)[:200],\n             )\n             _add_sequential(tool_call)\n             continue"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -2443,6 +2443,24 @@ def test_invalid_json_args_are_rejected_without_dispatch(self, agent):\n         assert \"valid json object\" in messages[0][\"content\"].lower()\n         assert \"tool was not executed\" in messages[0][\"content\"].lower()\n \n+    def test_none_args_rejected_without_dispatch(self, agent):\n+        \"\"\"None arguments must not crash the dispatch path. Current contract:\n+        malformed (non-string, non-JSON-object) args are rejected without\n+        executing the tool — same as invalid JSON strings. The mainline\n+        run_conversation path normalizes None to \"{}\" BEFORE dispatch (see\n+        test_tool_call_none_args_verbose_logging_does_not_crash), so this\n+        direct-dispatch path only needs to degrade gracefully, not coerce.\"\"\"\n+        tc = _mock_tool_call(name=\"web_search\", arguments=None, call_id=\"c1\")\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc])\n+        messages = []\n+        with patch(\"run_agent.handle_function_call\", return_value=\"ok\") as mock_hfc:\n+            agent._execute_tool_calls(mock_msg, messages, \"task-1\")\n+            mock_hfc.assert_not_called()\n+        assert len(messages) == 1\n+        assert messages[0][\"role\"] == \"tool\"\n+        assert messages[0][\"tool_call_id\"] == \"c1\"\n+        assert \"tool was not executed\" in messages[0][\"content\"].lower()\n+\n     def test_result_truncation_over_100k(self, agent, tmp_path, monkeypatch):\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n         (tmp_path / \".hermes\").mkdir()","@@ -2707,6 +2725,20 @@ def test_malformed_json_args_forces_sequential(self, agent):\n                 mock_seq.assert_called_once()\n                 mock_con.assert_not_called()\n \n+    def test_none_args_batch_does_not_crash_parallelism_gating(self, agent):\n+        \"\"\"Non-string tool arguments must not crash the segment planner —\n+        the None-args call becomes a sequential barrier and the batch\n+        dispatches without raising.\"\"\"\n+        tc1 = _mock_tool_call(name=\"web_search\", arguments='{}', call_id=\"c1\")\n+        tc2 = _mock_tool_call(name=\"web_search\", arguments=None, call_id=\"c2\")\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc1, tc2])\n+        messages = []\n+        with patch.object(agent, \"_execute_tool_calls_sequential\") as mock_seq:\n+            with patch.object(agent, \"_execute_tool_calls_concurrent\") as mock_con:\n+                agent._execute_tool_calls(mock_msg, messages, \"task-1\")\n+                mock_seq.assert_called_once()\n+                mock_con.assert_not_called()\n+\n     def test_non_dict_args_forces_sequential(self, agent):\n         \"\"\"Tool arguments that parse to a non-dict type should fall back to sequential.\"\"\"\n         tc1 = _mock_tool_call(name=\"web_search\", arguments='{}', call_id=\"c1\")","@@ -2719,6 +2751,22 @@ def test_non_dict_args_forces_sequential(self, agent):\n                 mock_seq.assert_called_once()\n                 mock_con.assert_not_called()\n \n+    def test_dict_args_batch_forces_sequential_without_crash(self, agent):\n+        \"\"\"Pre-parsed dict arguments (non-string) must not crash the planner.\n+        Current contract: the mainline loop normalizes dict args to JSON\n+        strings BEFORE dispatch, so raw dicts reaching the gate are treated\n+        as barriers (defensive sequential), consistent with the executors\n+        rejecting non-string args rather than repairing them.\"\"\"\n+        tc1 = _mock_tool_call(name=\"web_search\", arguments={\"q\": \"alpha\"}, call_id=\"c1\")\n+        tc2 = _mock_tool_call(name=\"web_search\", arguments={\"q\": \"beta\"}, call_id=\"c2\")\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc1, tc2])\n+        messages = []\n+        with patch.object(agent, \"_execute_tool_calls_sequential\") as mock_seq:\n+            with patch.object(agent, \"_execute_tool_calls_concurrent\") as mock_con:\n+                agent._execute_tool_calls(mock_msg, messages, \"task-1\")\n+                mock_seq.assert_called_once()\n+                mock_con.assert_not_called()\n+\n     def test_concurrent_executes_all_tools(self, agent):\n         \"\"\"Concurrent path should execute all tools and append results in order.\"\"\"\n         tc1 = _mock_tool_call(name=\"web_search\", arguments='{\"q\":\"alpha\"}', call_id=\"c1\")","@@ -2748,6 +2796,29 @@ def fake_handle(name, args, task_id, **kwargs):\n         assert \"beta\" in messages[1][\"content\"]\n         assert \"gamma\" in messages[2][\"content\"]\n \n+    def test_concurrent_none_args_rejected_without_crash(self, agent):\n+        \"\"\"Concurrent executor must not crash on arguments=None. Current\n+        contract (_parse_tool_arguments): non-object args are rejected with\n+        a structured error result and the tool is not executed; the valid\n+        sibling still runs. One result per call, in order.\"\"\"\n+        tc1 = _mock_tool_call(name=\"web_search\", arguments=None, call_id=\"c1\")\n+        tc2 = _mock_tool_call(name=\"web_search\", arguments='{\"q\":\"ok\"}', call_id=\"c2\")\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc1, tc2])\n+        messages = []\n+        seen_args = []\n+\n+        def fake_handle(name, args, task_id, **kwargs):\n+            seen_args.append((kwargs[\"tool_call_id\"], args))\n+            return \"ok\"\n+\n+        with patch(\"run_agent.handle_function_call\", side_effect=fake_handle):\n+            agent._execute_tool_calls_concurrent(mock_msg, messages, \"task-1\")\n+\n+        # Only the valid call executed; the None-args call was rejected.\n+        assert seen_args == [(\"c2\", {\"q\": \"ok\"})]\n+        assert [m[\"tool_call_id\"] for m in messages] == [\"c1\", \"c2\"]\n+        assert \"tool was not executed\" in messages[0][\"content\"].lower()\n+\n     def test_concurrent_preserves_order_despite_timing(self, agent):\n         \"\"\"Even if tools finish in different order, messages should be in original order.\"\"\"\n         import time as _time","@@ -4121,6 +4192,25 @@ def test_tool_calls_then_stop(self, agent):\n         assert mock_handle_function_call.call_args.kwargs[\"tool_call_id\"] == \"c1\"\n         assert mock_handle_function_call.call_args.kwargs[\"session_id\"] == agent.session_id\n \n+    def test_tool_call_none_args_verbose_logging_does_not_crash(self, agent):\n+        self._setup_agent(agent)\n+        agent.verbose_logging = True\n+        tc = _mock_tool_call(name=\"web_search\", arguments=None, call_id=\"c1\")\n+        resp1 = _mock_response(content=\"\", finish_reason=\"tool_calls\", tool_calls=[tc])\n+        resp2 = _mock_response(content=\"Done searching\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [resp1, resp2]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"search result\") as mock_handle_function_call,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"search something\")\n+\n+        assert result[\"final_response\"] == \"Done searching\"\n+        assert mock_handle_function_call.call_args.args[:2] == (\"web_search\", {})\n+\n     def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent):\n         self._setup_agent(agent)\n         tc = _mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}