{"repo":"NousResearch/hermes-agent","public_id":"17770","display_label":"Concurrent hermes_tools RPC response mismatch bug fix","summary":"These issues and pull requests address a bug where concurrent tool calls from execute_code cause the hermes_tools RPC client to mismatch responses. The proposed fix serializes concurrent RPC calls to prevent this.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17894},"resolution":{"state":"resolved","by":"pr-17894"},"freshness":{"state":"verified","last_verified_at":"2026-04-30T11:32:23+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":17770,"pr_number":17894,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":17770,"pr_number":17902,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":17770,"title":"[Bug]: hermes_tools RPC client mismatches responses under concurrent tool calls from execute_code","url":null,"state":"closed"},{"kind":"pr","number":17771,"title":"fix(tools): serialize concurrent hermes_tools RPC calls from execute_code","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"939f5f623","number":0},{"kind":"pr","value":"#17902","number":17902}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17872,"title":"fix(tools): serialize concurrent hermes_tools RPC calls from execute_code","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17902","number":17902},{"kind":"pr","value":"#17771","number":17771},{"kind":"pr","value":"#17770","number":17770}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":17894,"title":"fix(tools): serialize concurrent hermes_tools RPC calls from execute_code (#17770)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_code_execution.py","tools/code_execution_tool.py"],"fix_hunks":[{"file":"tests/tools/test_code_execution.py","hunks":["@@ -114,14 +114,30 @@ def test_convenience_helpers_present(self):\n         self.assertIn(\"def json_parse(\", src)\n         self.assertIn(\"def shell_quote(\", src)\n         self.assertIn(\"def retry(\", src)\n-        self.assertIn(\"import json, os, socket, shlex, time\", src)\n+        self.assertIn(\"import json, os, socket, shlex, threading, time\", src)\n \n     def test_file_transport_uses_tempfile_fallback_for_rpc_dir(self):\n         src = generate_hermes_tools_module([\"terminal\"], transport=\"file\")\n-        self.assertIn(\"import json, os, shlex, tempfile, time\", src)\n+        self.assertIn(\"import json, os, shlex, tempfile, threading, time\", src)\n         self.assertIn(\"os.path.join(tempfile.gettempdir(), \\\"hermes_rpc\\\")\", src)\n         self.assertNotIn('os.environ.get(\"HERMES_RPC_DIR\", \"/tmp/hermes_rpc\")', src)\n \n+    def test_uds_transport_serializes_concurrent_calls(self):\n+        \"\"\"Regression: UDS _call() must hold a lock across send+recv so that\n+        concurrent tool calls from multiple threads don't interleave on the\n+        shared socket and receive each other's responses.\"\"\"\n+        src = generate_hermes_tools_module([\"terminal\"], transport=\"uds\")\n+        self.assertIn(\"_call_lock = threading.Lock()\", src)\n+        self.assertIn(\"with _call_lock:\", src)\n+\n+    def test_file_transport_serializes_seq_allocation(self):\n+        \"\"\"Regression: file transport _call() must allocate `_seq` under a\n+        lock, otherwise concurrent threads can pick the same seq and clobber\n+        each other's request files.\"\"\"\n+        src = generate_hermes_tools_module([\"terminal\"], transport=\"file\")\n+        self.assertIn(\"_seq_lock = threading.Lock()\", src)\n+        self.assertIn(\"with _seq_lock:\", src)\n+\n \n class TestExecuteCodeRemoteTempDir(unittest.TestCase):\n     def test_execute_remote_uses_backend_temp_dir_for_sandbox(self):","@@ -226,6 +242,64 @@ def test_runtime_exception(self):\n         result = self._run(\"raise ValueError('test error')\")\n         self.assertEqual(result[\"status\"], \"error\")\n \n+    def test_concurrent_tool_calls_match_responses(self):\n+        \"\"\"Regression for the UDS RPC race: multiple threads inside the\n+        sandbox calling terminal() concurrently must each receive their own\n+        response, not another thread's.\n+\n+        Before the fix, `_sock` and the recv-loop were shared without a\n+        lock, so responses (written FIFO by the single-threaded server)\n+        got delivered to whichever client thread happened to win the\n+        recv() race. That surfaced as each thread seeing another thread's\n+        output.\n+\n+        The mock dispatcher sleeps briefly to guarantee the requests\n+        overlap on the socket.\n+        \"\"\"\n+        code = '''\n+import threading\n+from concurrent.futures import ThreadPoolExecutor\n+from hermes_tools import terminal\n+\n+N = 10\n+\n+def call(i):\n+    r = terminal(f\"echo TAG-{i}\")\n+    return i, r.get(\"output\", \"\")\n+\n+with ThreadPoolExecutor(max_workers=N) as ex:\n+    results = list(ex.map(call, range(N)))\n+\n+mismatches = [(i, out) for i, out in results if f\"TAG-{i}\" not in out]\n+if mismatches:\n+    print(f\"MISMATCH {len(mismatches)}/{N}: {mismatches[:3]}\")\n+else:\n+    print(f\"OK {N}/{N}\")\n+'''\n+\n+        def slow_mock(function_name, function_args, task_id=None, user_task=None):\n+            import time as _t\n+            if function_name == \"terminal\":\n+                _t.sleep(0.05)  # ensure requests overlap on the socket\n+                cmd = function_args.get(\"command\", \"\")\n+                # Echo semantics: strip leading \"echo \" and return the rest\n+                out = cmd[5:] if cmd.startswith(\"echo \") else f\"mock: {cmd}\"\n+                return json.dumps({\"output\": out, \"exit_code\": 0})\n+            return _mock_handle_function_call(\n+                function_name, function_args, task_id=task_id, user_task=user_task\n+            )\n+\n+        with patch(\"model_tools.handle_function_call\", side_effect=slow_mock):\n+            raw = execute_code(\n+                code=code,\n+                task_id=\"test-concurrent\",\n+                enabled_tools=list(SANDBOX_ALLOWED_TOOLS),\n+            )\n+        result = json.loads(raw)\n+        self.assertEqual(result[\"status\"], \"success\", msg=result)\n+        self.assertIn(\"OK 10/10\", result[\"output\"],\n+                      msg=f\"Concurrent tool calls mismatched: {result['output']!r}\")\n+\n     def test_excluded_tool_returns_error(self):\n         \"\"\"Script calling a tool not in the allow-list gets an error from RPC.\"\"\"\n         code = \"\"\""]},{"file":"tools/code_execution_tool.py","hunks":["@@ -224,9 +224,14 @@ def retry(fn, max_attempts=3, delay=2):\n \n _UDS_TRANSPORT_HEADER = '''\\\n \"\"\"Auto-generated Hermes tools RPC stubs.\"\"\"\n-import json, os, socket, shlex, time\n+import json, os, socket, shlex, threading, time\n \n _sock = None\n+# The RPC server handles a single client connection serially and has no\n+# request-id in the protocol, so concurrent _call() invocations from multiple\n+# threads (e.g. ThreadPoolExecutor) would race on the shared socket and get\n+# each other's responses. Serialize the entire send+recv round-trip.\n+_call_lock = threading.Lock()\n ''' + _COMMON_HELPERS + '''\\\n \n def _connect():","@@ -239,17 +244,18 @@ def _connect():\n \n def _call(tool_name, args):\n     \"\"\"Send a tool call to the parent process and return the parsed result.\"\"\"\n-    conn = _connect()\n     request = json.dumps({\"tool\": tool_name, \"args\": args}) + \"\\\\n\"\n-    conn.sendall(request.encode())\n-    buf = b\"\"\n-    while True:\n-        chunk = conn.recv(65536)\n-        if not chunk:\n-            raise RuntimeError(\"Agent process disconnected\")\n-        buf += chunk\n-        if buf.endswith(b\"\\\\n\"):\n-            break\n+    with _call_lock:\n+        conn = _connect()\n+        conn.sendall(request.encode())\n+        buf = b\"\"\n+        while True:\n+            chunk = conn.recv(65536)\n+            if not chunk:\n+                raise RuntimeError(\"Agent process disconnected\")\n+            buf += chunk\n+            if buf.endswith(b\"\\\\n\"):\n+                break\n     raw = buf.decode().strip()\n     result = json.loads(raw)\n     if isinstance(result, str):","@@ -265,24 +271,30 @@ def _call(tool_name, args):\n \n _FILE_TRANSPORT_HEADER = '''\\\n \"\"\"Auto-generated Hermes tools RPC stubs (file-based transport).\"\"\"\n-import json, os, shlex, tempfile, time\n+import json, os, shlex, tempfile, threading, time\n \n _RPC_DIR = os.environ.get(\"HERMES_RPC_DIR\") or os.path.join(tempfile.gettempdir(), \"hermes_rpc\")\n _seq = 0\n+# `_seq += 1` is not atomic (read-modify-write), so concurrent _call()\n+# invocations from multiple threads could allocate the same sequence number\n+# and clobber each other's request files. Guard seq allocation with a lock.\n+_seq_lock = threading.Lock()\n ''' + _COMMON_HELPERS + '''\\\n \n def _call(tool_name, args):\n     \"\"\"Send a tool call request via file-based RPC and wait for response.\"\"\"\n     global _seq\n-    _seq += 1\n-    seq_str = f\"{_seq:06d}\"\n+    with _seq_lock:\n+        _seq += 1\n+        seq = _seq\n+    seq_str = f\"{seq:06d}\"\n     req_file = os.path.join(_RPC_DIR, f\"req_{seq_str}\")\n     res_file = os.path.join(_RPC_DIR, f\"res_{seq_str}\")\n \n     # Write request atomically (write to .tmp, then rename)\n     tmp = req_file + \".tmp\"\n     with open(tmp, \"w\") as f:\n-        json.dump({\"tool\": tool_name, \"args\": args, \"seq\": _seq}, f)\n+        json.dump({\"tool\": tool_name, \"args\": args, \"seq\": seq}, f)\n     os.rename(tmp, req_file)\n \n     # Wait for response with adaptive polling\n"]}]},{"kind":"pr","number":17902,"title":"fix(tools): serialize concurrent hermes_tools RPC calls from execute_code","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_code_execution.py","tools/code_execution_tool.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}