Skip to content

pre_tool_call hook payload has an empty session_id for tool calls dispatched from inside execute_code #145854

Open

Description

@pcsokonay

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:2496 and agent/tool_executor.py:680 both pass session_id through _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_payload falls 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

  1. Register a pre_tool_call hook (any matcher covering terminal and execute_code) that appends json.load(sys.stdin)["session_id"] to a file.
  2. In one turn, call execute_code with from hermes_tools import terminal; terminal("true").
  3. Two rows land: the execute_code row has the session id, the terminal row has "".

Happy to test a patch against this install if useful.

Activity

  1. added
    comp/agentCore agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint
    comp/toolsTool registry, model_tools, toolsets
    P2Medium — degraded but workaround exists
    type/bugSomething isn't working
    on Oct 28, 2026
  2. liuhao1024 commented on Oct 28, 2026

    @liuhao1024

    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 (CreatePullRequest permission 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_id alone, so the nested call's _CallIds carried no session and _build_payload fell through to "":

    • local session kernel: CellAuthority captured only task_id; _invoke called handle_function_call(..., task_id=self.task_id)
    • remote session kernel + remote per-call ship: _rpc_poll_loop defaulted to _default_dispatch(task_id)

    Fix: thread session_id through execute_code (the registry already signature-injects it next to task_id) into CellAuthority, _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:

    • New end-to-end regression: a hermes_tools call from inside a cell reaches handle_function_call with the cell's session_id (new test in tests/tools/test_code_kernel.py under TestPerCellRpcAuthority), plus a _default_dispatch unit 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/check lint clean with no new health findings.
    • Mutation check: reverting the CellAuthority._invoke change 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.

  3. pcsokonay commented on Oct 28, 2026

    @pcsokonay
    Author

    Independently verified against a live install on v0.22.5 (bf0203a, git install) — not current main.

    Portability: cherry-picking 4c963b4 onto v0.22.5 conflicts only in tools/code_execution_tool.py, and only on indentation (main nests that block in an extra try: that 0.22.5 lacks). Keeping 0.22.5's structure and adding session_id=session_id at 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.py doesn't exist on 0.22.5, hence 71 rather than 75)
    • Mutation check reproduced: reverting only CellAuthority._invoke makes test_nested_rpc_call_carries_the_cell_session_id fail with None != '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 ""), and registry.dispatch → _kwargs_accepted_by() narrows kwargs by handler signature — so the new optional parameter on execute_code is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/agentCore agent runtime: loop, agent_init, prompt builder, context-compression, responses endpointcomp/toolsTool registry, model_tools, toolsetstool/code-execexecute_code sandboxtype/bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions