ask_user: suspend on a message_response gate instead of answering synchronously - #500
Merged
Merged
Conversation
Covers the suspend/resume mechanism CL-7190 introduces: interaction-tools' own beforeToolExtension decision shape, and a new reactor-level regression suite (vendor/intx/inference had zero tests before this) driving a real createReactor cycle to prove a suspended tool call produces no tool.done and no re-inference, that a correlated reply clears the gate and drives exactly one more infer, and that two concurrent questions answered out of order each resolve their own gate.
…chronously The director re-inferred as soon as ask_user's tool call resolved, so the agent regularly answered its own question — the only thing telling it to wait was prose in the tool result. ask_user now contributes a beforeToolExtension that posts the question card, then returns a suspend decision on a message_response gate, reaching the reactor's existing suspendOnGate — the same mechanism approvals already use. The turn parks with no tool.done and no re-inference until a correlated reply arrives (or the gate's explicit timeout fires with a synthetic error result); neither the reactor nor the director special-cases ask_user by name. ToolBundle gains an optional beforeToolExtension a bundle can contribute independent of the tool's name, composed into the reactor's extension list alongside the authz extension. signals.ts adds "message_response" to SignalKind. registerSignalCorrelation now fails loud for any signal kind other than "approval" rather than silently mis-persisting one it has no co-write for (ask_user's suspend never reaches this RPC — it's fully local via suspendOnGate/gates.register).
…xample Records the new local deltas this introduces in vendor/intx/agent, vendor/intx/inference, vendor/intx/types, and vendor/intx/hub-sessions in both VENDORED.md and each package's VENDORED-FROM file, with matching tree-hash updates in scripts/checks/kill-dates.txt. DESIGN.md's disclosure example referenced ask_user's now-removed "do not repeat this in prose" instruction; swapped for request_connection's equivalent, still-live one.
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ask_userposted its question card and returned a synchronousToolResultimmediately. The only thing telling the agent to wait was a sentence of prose in the result — the director never enforced it, soWorkbenchDirector.decidefell through toDefaultDirector, which re-inferred as soon as the tool-result count reached zero. Net effect: the agent (weaker models especially) regularly answered its own question and proceeded on a guess the user never gave.The platform already defines the right primitive —
GateTypeenumeratesmessage_responsealongsideapproval— with zero implementations anywhere. This wires it up.ask_usernow contributes abeforeToolExtension(mirroring how the authz extension already suspends approval-gated calls) that posts the question card via the existingpostQuestion, then returns{ type: "suspend", gate: { type: "message_response", ... } }instead of aToolResult. This reaches the reactor's pre-existingsuspendOnGate, which registers the gate, durably persists thePendingOperation, and commits before returning to its loop — restart-safe, exactly like an approval suspension.correlationIdis minted frompostQuestion's ownquestionId(not a throwaway random id) — the id that will let CL-7191 (stacked on this branch) resolve the specific gate an answer is for, once it lands.timeoutMsis explicit (1 hour); a timeout clears the gate with a synthetic error result rather than hanging forever.ask_userby name — the fix lives entirely in@corbits/interaction-tools, via a newToolBundle.beforeToolExtensionany tool package can contribute.Regression test:
vendor/intx/inference/src/reactor.test.ts(new — this package had zero tests before) drives a realcreateReactorcycle, not a mocked decision shape, and proves: (1) a suspended call produces notool.doneand no re-inference, (2) a correlated reply clears the gate and drives exactly one more infer, (3) two concurrent questions answered out of order each resolve their own gate — never cross-wired.Important caveat for reviewers
This PR alone does not let a live answer resolve the gate early. The mail path that would stamp
headers.interchangeCorrelationIdon the answer (so the reactor's pre-existingtryCorrelatecan match it) is CL-7191's stacked PR, not this one — it was flagged during review that landing correlation-plumbing here would blur the two tickets' scopes. Until CL-7191 merges, a parked question is answered by whichever of its own two paths fires first: the user's actual answer (which the current, unfixedpackages/chatroute still relays as an ordinary message with no correlation, so it won't resolve the gate) or the 1-hour timeout with a synthetic error. That's still a strict improvement over today's silent self-answer — CL-7190's own acceptance criteria explicitly call for this timeout fallback — but it means these two PRs should be reviewed and merged together, not independently.What changed
packages/interaction-tools/src/tool.ts—beforeAskUser(the newbeforeToolExtension);run()now fails loud if ever reached forask_user(naming the exact wiring defect:ResolvedTools.beforeToolExtensionsnot composed).vendor/intx/agent/src/tool.ts,agent.ts—ToolBundle.beforeToolExtension(optional), composed intoResolvedTools.beforeToolExtensionsalongside authz.vendor/intx/inference/src/reactor.ts— amessage_responsebranch inresumePendingOperation, andtimeoutMessageForso a timed-out question gets its own wording instead of reusing "approval timed out".vendor/intx/types/src/signals.ts—"message_response"added toSignalKind.vendor/intx/hub-sessions/src/hub-session-lookups.ts—registerSignalCorrelationnow fails loud for any signal kind other than"approval"(traced:ask_user's suspend is fully local viasuspendOnGate/gates.registerand never reaches this RPC — it's used exclusively by the workflow-deploy/supervisor "ask" capability's park-notify path — so this is inert hardening, not a live path change).VENDORED.md/VENDORED-FROM/scripts/checks/kill-dates.txt— deltas and tree-hash updates for the four touched vendor packages.DESIGN.md— swapped a disclosure example that referencedask_user's now-removed prose forrequest_connection's equivalent, still-live one.Interchange notes
No upstream Interchange defect found or filed. Every mechanism this PR wires into (
BeforeToolExtension,PendingOperation,GateType.message_response, the reactor's dispatch loop honoringallow/block/suspend) was already fully present in the vendoredvendor/intx/*trees, untouched — confirmed viagit show HEADdiffs before making any change. This PR only adds the composition (ToolBundle.beforeToolExtension→ResolvedTools.beforeToolExtensions) and themessage_responseresume branch that nothing had wired up yet.A separate, narrower architectural note was filed as a follow-up (not a blocker for either PR): CL-7248 — a crash between
postQuestion's external POST andsuspendOnGate's durable commit can orphan a posted question card on restart. Flagged during review, out of scope for CL-7190/CL-7191.Test plan
cd packages/interaction-tools && bun run typecheck && bun test— 11/11 passcd vendor/intx/inference && bun run typecheck && bun test— 3/3 pass (new suite)cd vendor/intx/hub-sessions && bun run typecheck && bun test— 44/44 passcd vendor/intx/agent && bun run typecheck— cleancd vendor/intx/types && bun run typecheck— cleanbun run check:killdates,check:tool-package-pins,check:tool-package-freshness,check:licenses— all passbunx prettier --checkon every touched fileDO NOT MERGE — stacked with CL-7191 (branching from this branch), both intended to land together.
Closes CL-7190.
Re-pin disclosure
vendor/intx/inference/src/reactor.tscarries a real local delta here:git diff --stat origin/mainreports72 insertions(+), 40 deletions(-)(112 changed lines total). At the nextvendor/intx/inferencere-pin, this will need to be re-applied by hand — concretely:timeoutMessageFor(kind: SignalKind)(new helper) — exhaustive overSignalKindso a timed-out gate gets kind-appropriate wording ("approval timed out" vs "question timed out with no answer") instead of a hardcoded approval-only string.ResumeDispatch's new"answer_result"variant, andresumePendingOperation's restructuring: the single un-conditionalApprovalDecisionJSON-parse that used to run before theswitchmoved inside the"approval"case, with a sibling"message_response"case added that takes the correlated reply's body verbatim (no parse) as the parked call's tool result.error_result/answer_resultcases merged in the dispatch handler that clears the gate silently and lets the director re-infer once.Verified before writing this: I confirmed there is exactly one call site of
timeoutMessageFor(the previous hardcoded"approval timed out"string) and confirmed the outerswitch (op.kind)is exhaustive viaassertNever, so a futureSignalKindaddition fails to compile here until it's handled — a re-pin conflict here is a compile error, not a silent gap.Upstream-worthy? Plausibly yes, in whole or in part — the pattern here (a
SignalKind-keyed resume rail, withapproval's JSON-decision parse as one case among several rather than baked into the shared path) is generic and not workbench-specific; nothing in this delta depends on anything else in this repo. I'm not filing this upstream myself per instructions — flagging it here for the owner to fold into Interchange's own backlog if they agree it's worth upstreaming.