feat(nodejs): plumb AbortSignal through ToolInvocation (#1433)#1701
feat(nodejs): plumb AbortSignal through ToolInvocation (#1433)#1701gimenete wants to merge 2 commits into
Conversation
Add a cooperative cancellation signal to tool handlers so session.abort() (and a new session.cancelToolCall(toolCallId)) can cancel in-flight tool handlers. Handlers opt in via the AbortSignal on their ToolInvocation; handlers that ignore it run to completion, preserving existing behavior. Closes github#1433 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Add cooperative cancellation for Node.js tool handlers by wiring an AbortSignal into tool invocations, aborting in-flight handlers on session abort/disconnect, and documenting/testing the behavior.
Changes:
- Pass an
AbortSignalto tool handlers and abort it onsession.abort()/disconnect(). - Track in-flight tool calls and add
cancelToolCall(toolCallId)to abort a single handler. - Extend docs and add an e2e assertion that tool-handler signals are aborted.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| nodejs/test/e2e/abort.e2e.test.ts | Adds an e2e check that tool handler AbortSignal is triggered by session.abort() |
| nodejs/src/types.ts | Extends ToolInvocation with a signal: AbortSignal and documentation |
| nodejs/src/session.ts | Tracks in-flight tool calls via AbortController, aborts them on abort()/disconnect(), adds cancelToolCall() |
| nodejs/README.md | Documents cooperative tool cancellation and the new cancelToolCall API |
| let signalAbortedResolve!: (value: void) => void; | ||
| const signalAborted = new Promise<void>((resolve) => { | ||
| signalAbortedResolve = resolve; | ||
| }); |
| handler: async ({ value }) => { | ||
| handler: async ({ value }, { signal }) => { | ||
| toolStartedResolve(value); | ||
| signal.addEventListener("abort", () => signalAbortedResolve()); |
| handler: async ({ value }) => { | ||
| handler: async ({ value }, { signal }) => { | ||
| toolStartedResolve(value); | ||
| signal.addEventListener("abort", () => signalAbortedResolve()); |
| toolCallId: string; | ||
| toolName: string; | ||
| arguments: unknown; | ||
| /** | ||
| * An `AbortSignal` that aborts when `session.abort()` or | ||
| * `session.cancelToolCall(toolCallId)` is invoked while this handler is | ||
| * in flight. Handlers may opt in to cooperative cancellation by forwarding | ||
| * it to abortable APIs (`fetch(url, { signal })`, `child_process.spawn`, | ||
| * etc.) or by checking `signal.aborted`. Handlers that ignore it continue | ||
| * to run to completion, preserving existing behavior. | ||
| */ | ||
| signal: AbortSignal; |
There was a problem hiding this comment.
Keeping signal required on purpose for cross-SDK consistency — the same field is non-optional in the Python, Go, .NET, Rust, and Java equivalents, and the SDK always injects a live signal at runtime, so handlers never have to null-check it. The only friction is for consumers who hand-construct/mock ToolInvocation in tests, which is a one-line addition (signal: new AbortController().signal). Given it's a new field on a handler-input type rather than a return type, the runtime guarantee felt worth the minor upgrade cost.
| cancelToolCall(toolCallId: string): boolean { | ||
| const controller = this.inFlightToolCalls.get(toolCallId); | ||
| if (!controller) { | ||
| return false; | ||
| } | ||
| controller.abort(); | ||
| return true; | ||
| } |
- Fix signalAbortedResolve type to () => void in abort e2e test
- Handle already-aborted signal and use { once: true } listener
- cancelToolCall now removes the in-flight entry so repeat/completed
ids return false (consistent with other SDKs)
- Add e2e test covering cancelToolCall (true for in-flight, false for
unknown and already-cancelled ids) with recorded snapshot
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| this._abortInFlightToolCalls(); | ||
| await this.connection.sendRequest("session.abort", { | ||
| sessionId: this.sessionId, | ||
| }); |
There was a problem hiding this comment.
I think we need to be sending session.abort before calling _abortInFlightToolCalls.
If tools get aborted first, their AbortSignal will fire, and their promises will be rejected. This sends back a tool call result to the runtime. If the runtime receives this before the session abort message, it will treat it as an error and might even start a new agent invocation to process the failed tool call.
I know that as the code is structured right now, that doesn't actually happen because there are no awaits in between lines 1228 and 1229. But that means it's working just by chance, and could stop working if anything about this code is refactored in the future (e.g., awaiting something between those two lines).
There was a problem hiding this comment.
Update: this would cease to be an concern after updating the flow to match #1701 (comment)
|
Thanks for these PRs, @gimenete. The feature is a good idea, but the way it's implemented here doesn't look right to me. This assumes there's only one client, and that session abort is always triggered by that client. But in reality there can be multiple clients on the same session, and the tool calls might be running on a different client than the one that requests the abort, or the abort might be triggered by something inside the runtime rather than a client. Instead, I think the right design is for clients to listen for the session I'd also prefer not to have the Does this make sense? |
Summary
Implements the feature requested in #1433 for the Node.js SDK: plumb an
AbortSignalthroughToolInvocationso thatsession.abort()(and a newsession.cancelToolCall(toolCallId)) can cooperatively cancel in-flight tool handlers.Previously
session.abort()only cancelled the agentic loop — a currently-executing tool handler ran to completion regardless, forcing consumers to implement OS-level process-tree kills.Changes
ToolInvocationnow carries a requiredsignal: AbortSignalthat aborts whensession.abort()orsession.cancelToolCall(toolCallId)is invoked while the handler is in flight.session.abort()aborts the signals of all in-flight tool handlers before sending the abort RPC.session.cancelToolCall(toolCallId)(new) cancels a single in-flight handler without aborting the broader agentic loop; returnstrueif a matching call was found.session.disconnect()aborts in-flight handlers so they can release resources.Map<toolCallId, AbortController>.Backwards compatibility
Handlers that ignore the signal continue to run to completion — existing handlers work unchanged.
Tests
Extended the
aborte2e test to assert the handler'sAbortSignalfires whensession.abort()is called. Typecheck, lint, format check, and unit tests all pass.Closes #1433
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com