Skip to content

feat: add mid-execution cancellation#781

Open
notowen333 wants to merge 1 commit intostrands-agents:mainfrom
notowen333:agent-cancel
Open

feat: add mid-execution cancellation#781
notowen333 wants to merge 1 commit intostrands-agents:mainfrom
notowen333:agent-cancel

Conversation

@notowen333
Copy link
Copy Markdown
Contributor

Description

Motivation

We have cancellations in sdk-python but not in TS.

The SDK currently provides no way to stop an agent mid-execution. Once agent.invoke() or agent.stream() is called, the caller must wait for the model to reach a natural stop. This is a problem for real-world applications where cancellation is routine: HTTP servers need to abort work when clients disconnect, UIs need a "stop generating" button, and batch pipelines need per-task timeouts. Without SDK-level support, developers are forced to build fragile workarounds or let orphaned work run to completion.

Public API Changes

LocalAgent interface (public contract for tool authors and framework integrators)

New cancellationSignal readonly property -- exposes the cancellation signal for the current invocation, or undefined when idle. This is the primary surface tools use to participate in cooperative cancellation:

// Polling pattern
callback: async ({ items }, context) => {
  const results = []
  for (const item of items) {
    if (context.agent.cancellationSignal?.aborted) return results
    results.push(await process(item))
  }
  return results
}

// Signal forwarding pattern
callback: async ({ url }, context) => {
  const res = await fetch(url, { signal: context.agent.cancellationSignal })
  return res.text()
}

Agent class (concrete implementation)

New cancel() method -- cooperatively cancels the current invocation:

const agent = new Agent({ model, tools })

setTimeout(() => agent.cancel(), 5000)
const result = await agent.invoke('Do something long')
console.log(result.stopReason) // 'cancelled'

New isCancelled getter -- convenience boolean for checking cancellation state. Returns false when idle.

New cancellationSignal getter -- implements the LocalAgent property, backed by an internal AbortController composed (via AbortSignal.any()) with any external signal passed through InvokeOptions.

InvokeOptions (options for invoke() / stream())

New cancellationSignal option -- accepts an external AbortSignal for framework-driven cancellation. The agent composes this with its own internal controller, so both agent.cancel() and the external signal can trigger cancellation independently:

// Timeout-based
const result = await agent.invoke('Hello', {
  cancellationSignal: AbortSignal.timeout(5000),
})

// Framework-driven (e.g., Express client disconnect)
app.post('/chat', async (req, res) => {
  const result = await agent.invoke(req.body.message, {
    cancellationSignal: req.signal,
  })
  res.json(result)
})

StopReason type

New 'cancelled' variant -- returned on AgentResult.stopReason when an invocation is cancelled.

StreamOptions (model layer)

New cancellationSignal field -- forwarded from the agent to the model stream call. Model implementations do not need to handle it; the agent checks the signal between streaming events.

All changes are backward compatible. Existing code that does not use cancellation is unaffected.

Use Cases

  • Server-side abort: Cancel agent work when an HTTP client disconnects, avoiding wasted compute
  • Timeout enforcement: Use AbortSignal.timeout() to cap execution time for batch or latency-sensitive pipelines
  • UI stop button: Call agent.cancel() from a UI event handler to let users stop generation on demand

Cancellation Flows

Cancellation is cooperative. The agent checks the composed signal at specific points and takes a different code path depending on where in the loop cancellation is detected. There are four distinct flows:

Flow 1: Top of the agent loop cycle

_throwIfCancelled() runs at the while (true) entry in _stream(). If already aborted, AgentCancelInterrupt is thrown. The catch block appends a synthetic "Cancelled by user" assistant message and returns stopReason: 'cancelled'.

Flow 2: During model response streaming

_throwIfCancelled() runs between each streamed event in _consumeModelStream(). This is the tightest checkpoint -- responsiveness is bounded by the gap between two consecutive stream chunks. The thrown AgentCancelInterrupt propagates through _callModel() (bypassing retry logic and AfterModelCallEvent) into _stream(), handled as in Flow 1. No partial assistant message is appended because the agent uses a deferred-append pattern.

Flow 3: Before tool execution

After the model returns tool_use blocks but before any tool runs, _stream() checks this.isCancelled directly (no throw needed -- no nested generator to unwind). If cancelled:

  1. The completed assistant message (with tool_use blocks) is appended.
  2. Synthetic error ToolResultBlocks ("Tool execution cancelled") are created for every pending tool and appended as a user message.
  3. The cancelled AgentResult is returned.

This preserves a valid assistant/user message sequence so the conversation can be resumed.

Flow 4: Between sequential tool executions

Inside the sequential loop in executeTools(), this.isCancelled is checked before each tool. Already-completed tools keep their real results; remaining tools get synthetic error results. Control returns to _stream(), which exits via Flow 1 or 3 on the next iteration.

Running tools are never forcibly interrupted -- they run to completion unless the tool checks context.agent.cancellationSignal itself.

Finally-block fallback

The _stream() finally block checks this.isCancelled to handle the edge case where the generator is terminated externally via .return() (e.g., a consumer breaking out of for-await-of). If cancelled but no result was produced, it appends the cancel message so the agent can be reinvoked.

AgentCancelInterrupt (internal)

An internal (not exported) Error subclass used to unwind nested yield* generator chains. The agent's streaming path is _stream() -> _callModel() -> _consumeModelStream(), each connected via yield*. A normal return from the innermost generator wouldn't propagate up -- throwing AgentCancelInterrupt does. It is caught exclusively in _stream() and converted to a cancelled AgentResult. In _callModel(), it bypasses retry logic and skips AfterModelCallEvent since there is no meaningful model result to report.

Flows 3 and 4 don't use AgentCancelInterrupt because they check this.isCancelled in _stream() / executeTools() directly -- there is no nested generator to unwind.

Vended tool update: httpRequest

The httpRequest tool was updated to demonstrate cancellation-aware tool design. Before this change, it managed its own AbortController for timeout handling. After:

// Before: manual AbortController + setTimeout/clearTimeout
const controller = new AbortController()
const timeoutId = globalThis.setTimeout(() => controller.abort(), timeout * 1000)
// ... fetch with controller.signal ...
// ... clearTimeout in both success and error paths ...

// After: AbortSignal.timeout + agent signal composition
const timeoutSignal = AbortSignal.timeout(timeout * 1000)
const agentSignal = context?.agent.cancellationSignal
const signal = agentSignal ? AbortSignal.any([timeoutSignal, agentSignal]) : timeoutSignal
// ... fetch with signal ...

This is simpler (no manual controller/timer cleanup) and cancellation-aware. On abort, it distinguishes the cause:

const reason = timeoutSignal.aborted ? `timed out after ${timeout} seconds` : 'cancelled'

Related Issues

#769

Documentation PR

Type of Change

New feature

Testing

Add unit and integ tests

  • I ran npm run check

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@notowen333 notowen333 requested review from pgrayy and zastrowm April 1, 2026 20:45
@github-actions github-actions bot added the strands-running <strands-managed> Whether or not an agent is currently running label Apr 1, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

Assessment: Comment

This is an excellent implementation of cooperative cancellation for the TypeScript SDK. The code is well-structured, follows existing patterns, and includes comprehensive testing.

Review Summary
  • API Design: Clean public API surface using familiar AbortController/AbortSignal patterns. The four cancellation flows are well-documented and the deferred-append pattern ensures message array consistency.
  • Code Quality: Implementation follows repository patterns with proper error handling, cleanup in finally blocks, and consistent naming conventions.
  • Testing: Comprehensive unit tests (16 tests covering all 4 cancellation flows) and integration tests across model providers.

Documentation PR: This PR introduces new public API surface (cancel(), cancellationSignal, 'cancelled' stop reason) that users will need to learn. Please add a link to a documentation PR or confirm if one is in progress.

Nice work aligning this with the Python SDK's cancellation capabilities! 🎉

@github-actions github-actions bot removed the strands-running <strands-managed> Whether or not an agent is currently running label Apr 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant