Repository navigation
pre_tool_call hook payload has an empty session_id for tool calls dispatched from inside execute_code #145854
Description
What happens
A tool call dispatched from inside execute_code (via the hermes_tools helpers) reaches the pre_tool_call shell hook with an empty session_id, while the outer execute_code call carries the real one.
Measured on a live install (v0.52.x line), a pre_tool_call hook that appends the payload's session_id to an audit log wrote these two rows for one turn:
| trigger | tool | session_id |
|---|---|---|
pre_tool_call |
execute_code |
20261028_120913_cc8ebc |
pre_tool_call |
terminal |
"" |
The nested call came from from hermes_tools import terminal; terminal("true") inside the sandbox.
Why it matters
The hook payload is documented (agent/shell_hooks.py, module docstring) as
{hook_event_name, tool_name, tool_input, session_id, cwd, extra}, and _build_payload fills it with
kwargs.get("session_id") or kwargs.get("parent_session_id") or "" — so an empty value is indistinguishable from "this call has no session".
Any consumer that attributes hook events to a turn is blind for sandbox-originated calls. Concretely: two identical calls in one execute_code batch produce two rows with the same (ts, tool, payload) and no session, which cannot be told apart from one callback registered twice — we had a health check red-ing for 90 minutes after every such batch before we taught it to skip session-less rows.
Where it looks like it is dropped
agent/agent_runtime_helpers.py:2496andagent/tool_executor.py:680both passsession_idthrough_dispatch_pre_tool_call_hooks(...)for the main path.- The nested path is
tools/connectors/dispatch.py:50→handle_function_call(plan.name, plan.arguments, **asdict(entry_ids), ...); for sandbox-originated calls the id set appears to carry no session, so_build_payloadfalls through to"".
Expected
A tool call made from inside execute_code should carry the parent turn's session_id (or parent_session_id if that is the intended field for a child context), the same way the outer execute_code call does.
Repro
- Register a
pre_tool_callhook (any matcher coveringterminalandexecute_code) that appendsjson.load(sys.stdin)["session_id"]to a file. - In one turn, call
execute_codewithfrom hermes_tools import terminal; terminal("true"). - Two rows land: the
execute_coderow has the session id, theterminalrow has"".
Happy to test a patch against this install if useful.
Activity
liuhao1024 commentedon Oct 28, 2026
Patched — the fix is on branch
liuhao/cron-bugfix-145854(commit 4c963b4, based on current main 07f075c). Opening the PR itself is currently blocked for this account (CreatePullRequestpermission denied), so please pull the branch directly, or say the word and I'll retry.Root cause (slightly downstream of where the issue points): all three sandbox dispatch paths built their dispatcher from the
task_idalone, so the nested call's_CallIdscarried no session and_build_payloadfell through to"":CellAuthoritycaptured onlytask_id;_invokecalledhandle_function_call(..., task_id=self.task_id)_rpc_poll_loopdefaulted to_default_dispatch(task_id)Fix: thread
session_idthroughexecute_code(the registry already signature-injects it next totask_id) intoCellAuthority,_default_dispatch/_rpc_poll_loop, and the remote kernel path — a nested call's hook payload now matches the outer call's. Optional parameter, no behavior change for callers that don't pass it.Verification:
hermes_toolscall from inside a cell reacheshandle_function_callwith the cell'ssession_id(new test intests/tools/test_code_kernel.pyunderTestPerCellRpcAuthority), plus a_default_dispatchunit pin.pytest tests/tools/test_code_kernel.py -q→ 29 passed; the sandbox-adjacent files (test_code_execution.py,test_code_execution_file_rpc.py,test_code_kernel_remote.py,test_execute_code_approval_cluster.py,test_terminal_inflight_reap_guard.py) → 75 passed, 5 subtests;scripts/checklint clean with no new health findings.CellAuthority._invokechange alone makes the new regression fail exactly as the issue describes.Happy to have this tested against your install once it lands on main — or straight from the branch if that's easier.
pcsokonay commentedon Oct 28, 2026
Independently verified against a live install on v0.22.5 (
bf0203a, git install) — not currentmain.Portability: cherry-picking
4c963b4onto v0.22.5 conflicts only intools/code_execution_tool.py, and only on indentation (main nests that block in an extratry:that 0.22.5 lacks). Keeping 0.22.5's structure and addingsession_id=session_idat both call sites (execute_in_remote_kernel,_run_remote_per_call) is enough; every other hunk applies unchanged.Results on a frozen worktree:
tests/tools/test_code_kernel.py— 29 passed (both new tests included)tests/tools/test_code_execution.py,test_code_execution_file_rpc.py,test_code_kernel_remote.py,test_execute_code_approval_cluster.py— 71 passed, 5 subtests (test_terminal_inflight_reap_guard.pydoesn't exist on 0.22.5, hence 71 rather than 75)CellAuthority._invokemakestest_nested_rpc_call_carries_the_cell_session_idfail withNone != '20261028_120913_cc8ebc'.Payload chain on 0.22.5, checked end to end:
_CallIds.session_id→hook_kwargs()→_dispatch_pre_tool_call_hooks(...)→agent/shell_hooks.py:97(kwargs.get("session_id") or … or ""), andregistry.dispatch→_kwargs_accepted_by()narrows kwargs by handler signature — so the new optional parameter onexecute_codeis exactly what makes the outer call's value available; no further wiring needed on this line.We'll confirm on the live gateway (audit rows gaining a session id) once it lands on main and the update pulls it. If the PR wants an author with CreatePullRequest: our account can open one from a fork carrying your commit unchanged (authorship stays yours) — say the word and I'll do that; otherwise we're happy to retry from yours if permissions change.