Skip to content

feat(hitl): guard sibling double-execution when ask_user_question interrupts mid-batch#294

Merged
danny-avila merged 2 commits into
mainfrom
feat/interrupting-tool-batch-guard
Jul 8, 2026
Merged

feat(hitl): guard sibling double-execution when ask_user_question interrupts mid-batch#294
danny-avila merged 2 commits into
mainfrom
feat/interrupting-tool-batch-guard

Conversation

@danny-avila

Copy link
Copy Markdown
Owner

Problem

A tool whose body raises a LangGraph interrupt() mid-execution — the ask_user_question shape, which suspends the run to collect a human answer — shares its in-process Promise.all with its sibling direct tools. When it interrupts, LangGraph's resume contract re-runs the whole interrupted node from the top, so a non-idempotent sibling (send_email, billing) that already completed on the first pass runs a second time on resume, duplicating the side effect.

This guards the double-execution Codex flagged on LibreChat #14139 (danny-avila/LibreChat#14139, thread on packages/api/src/agents/hitl/askUserQuestionTool.ts). Unlike the tool-approval HITL path (which gates the batch in PreToolUse before any tool runs), ask_user_question interrupts mid-batch, so a sibling's side effect can already have landed before the pause.

Empirical scoping (dist probe, not source-reading)

A runnable probe drove [ask_user_question, side_effect] through pause→resume against a real graph + MemorySaver, across batch shapes:

Shape Mode sibling Result (baseline)
all-direct legacy direct double (2×)
direct+direct event-driven direct double (2×)
ask(direct) + sibling(event) event-driven event safe (1×)

The exposure is narrow: only a sibling sharing the interrupter's in-process Promise.all double-executes. An event-dispatched sibling is already safe — the ToolNode awaits the whole direct group (where a body interrupt unwinds) before it dispatches event tools, so a dispatched sibling never runs on the first pass. Confirmed empirically: a direct tool body's interrupt() does suspend (no ALS-frame issue), and the double-execution is order-independent.

Fix — schedule interrupting tools first (not idempotent resume)

Add an opt-in RunConfig.interruptingToolNames (threaded Run → Graph → ToolNode, mirroring codeSessionToolNames). Tools named there are:

  1. always executed in-process — a body interrupt() only fires for a tool that runs inside the graph node, never for one dispatched to the host; and
  2. within a batch, scheduled as their own awaited group before their non-interrupting direct siblings. If one interrupts, the batch unwinds before any sibling runs, so the sibling executes exactly once (on resume).

Opt-out is byte-for-byte the prior behavior: when the set is empty or no batch call matches, direct-batch execution is an unchanged single Promise.all.

Idempotent resume (the other candidate) was rejected: the completed sibling ToolMessages aren't in graph state at interrupt time (the node hasn't returned), so it would require persisting per-call completion into checkpoint/store state — the exact machinery the tool-error-resume work took several rounds to stabilize — for a defect this narrow and prompt-mitigated. The scheduling guard stays entirely in execution order.

Validation

  • New spec src/specs/ask-user-question-batch.test.ts — pins [ask_user_question, side_effect] through pause→resume asserting the sibling runs exactly once (both orderings), plus the unguarded baseline (double) and the event-sibling-safe shape. Runs in-memory (StateGraph + MemorySaver), no LLM/Run machinery.
  • Dist probe (node -e against dist/cjs/main.cjs, per the tool-error-resume convention): without the set the exposed direct shapes double-execute (2×); with it they run exactly once (1×), order-independent, in both modes; the event-dispatched sibling stays safe. ✅ all cases pass.
  • Existing ToolNode batch/HITL suites (directToolHITLResumeScope, directToolHooks, ToolNode.outputReferences, ToolNode.onResultCompletion, ToolNode.eagerEventExecution, toolOutputReferences) — 112 passed, 0 regressions. Typecheck + ESLint clean.

Host wiring

The host opts in by passing the ask_user_question tool name in RunConfig.interruptingToolNames. The HumanInTheLoopConfig JSDoc (which previously documented sibling re-execution as an unavoidable "must be idempotent" contract) is updated to describe the guard and its precise scope.

…-execution

A tool whose body raises a LangGraph `interrupt()` mid-execution — the
`ask_user_question` shape, which suspends the run to collect a human
answer — shares its in-process `Promise.all` with sibling direct tools.
When it interrupts, LangGraph's resume contract re-runs the whole
interrupted node from the top, so a non-idempotent sibling (send_email,
billing) that already completed on the first pass runs a SECOND time on
resume, duplicating the side effect.

A runnable dist probe over the batch shapes (pause→resume, MemorySaver)
shows the exposure is narrow: only a sibling that shares the
interrupter's in-process `Promise.all` double-executes. An
event-dispatched sibling is already safe — the ToolNode awaits the whole
direct group (where a body interrupt unwinds) before it dispatches event
tools, so a dispatched sibling never runs on the first pass. This is why
the fix is scheduling within the direct group rather than idempotent
resume (which would have to persist per-call completion into checkpoint
state — the exact machinery the tool-error-resume work took several
rounds to stabilize, unjustified for this narrow, opt-in case).

Fix: add an opt-in `RunConfig.interruptingToolNames` (threaded through
Graph → ToolNode, mirroring `codeSessionToolNames`). Tools named there
are (1) always executed in-process — a body `interrupt()` only fires for
a tool that runs inside the graph node, never for one dispatched to the
host — and (2) within a batch, scheduled as their own awaited group
BEFORE their non-interrupting direct siblings. If one interrupts, the
batch unwinds before any sibling runs, so the sibling executes exactly
once (on resume). Opt-out is byte-for-byte the prior behavior: when the
set is empty or no batch call matches, direct-batch execution is an
unchanged single `Promise.all`.

Empirically validated (dist probe): without the set the exposed direct
shapes still double-execute (2x); with it they run exactly once (1x),
order-independent, in both event-driven and legacy modes; the
event-dispatched sibling stays safe. New spec
`src/specs/ask-user-question-batch.test.ts` pins [ask_user_question,
side_effect] through pause→resume asserting the sibling runs exactly
once, plus the unguarded baseline and the event-sibling-safe shape.

Guards the double-execution Codex flagged on LibreChat PR #14139
(danny-avila/LibreChat#14139); the host opts in by passing the
ask_user_question tool name in `interruptingToolNames`.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e4b4173e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/graphs/Graph.ts
// it only ever schedules an interrupting tool ahead of its
// siblings, so propagating it into subagents can prevent a
// double side effect but never introduce one.
childGraph.interruptingToolNames = this.interruptingToolNames;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t force child-only event tools onto the direct path

When a subagent keeps event-driven toolDefinitions but does not have the matching executable graphTools instance (for example self-spawned children, where buildChildInputs deliberately scrubs inherited graphTools), propagating the parent interruptingToolNames makes the child ToolNode classify that name as direct. The direct branch then invokes the schema-only stub instead of dispatching ON_TOOL_EXECUTE, so a child call to an inherited ask_user_question event definition fails with the schema-only “should not be invoked directly” error rather than reaching the host. Filter this set for child graphs to names that are actually present in the child’s graphTools, or avoid propagating it when the child cannot run the tool in-process.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9241532. Root cause was folding interruptingToolNames into direct-tool classification — that promoted a schema-only event stub onto the direct path. Removed the fold-in at both sites; the set now only reorders tools that are already independently direct (real graphTools, the only kind whose body can reach interrupt()). A name that's merely an inherited event toolDefinition (e.g. a self-spawned child that scrubs graphTools) stays dispatched via ON_TOOL_EXECUTE. In LibreChat ask_user_question is a graphTool → already in directToolNames, so the guard still applies. Added a spec case asserting an interrupting name that's only an event stub is host-dispatched, never invoked directly.

…dex #294)

Codex flagged that folding `interruptingToolNames` into direct-tool
classification breaks a self-spawned child that scrubs inherited
`graphTools` (SubagentExecutor.buildChildInputs, `config.self === true`)
but keeps the event `toolDefinition`. There the name resolves only to a
schema-only stub; forcing it direct invokes the stub, which throws
"should not be invoked directly in event-driven mode" instead of
dispatching ON_TOOL_EXECUTE to the host.

Root cause: the fold-in was wrong in principle. `interruptingToolNames`
should only REORDER tools that are already independently direct — a real
in-process graphTool is the only kind whose body can reach `interrupt()`.
A name that is merely an event `toolDefinition` has no in-process
implementation to run, so promoting it to the direct path can only ever
hit the stub. In the real host (LibreChat) `ask_user_question` is a
graphTool and is therefore already in `directToolNames`, so the guard
works without the fold-in — `runDirectBatchInterruptSafe` still schedules
it ahead of its direct siblings.

Fix: remove the fold-in at both classification sites (mixed-batch
partition and the Send-input `isLocalTool` check). The child-graph
propagation is now safe (it only reorders the child's direct group, a
no-op when the child has no executable instance for the name), so it
stays. Docs on `RunConfig`/`ToolNodeOptions.interruptingToolNames`,
`HumanInTheLoopConfig`, and the ToolNode field are corrected to say the
set reorders rather than promotes, and the tool must independently be
direct for the guard to apply.

New spec case pins it: a name in `interruptingToolNames` that is only a
schema-only event stub is dispatched to the host (HOST-HANDLED), never
invoked directly.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 9241532ca7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila danny-avila merged commit 103712e into main Jul 8, 2026
25 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant