Skip to content

LOU-V1: cancel a run with an AbortSignal - #42

Merged
LinuxDevil merged 2 commits into
mainfrom
lou-v1-cancellation
Oct 1, 2026
Merged

LinuxDevil merged 2 commits into
mainfrom
lou-v1-cancellation

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

What

Runs can now be cancelled with an AbortSignal:

const controller = new AbortController();
const result = await agent.send('Write a long report', { signal: controller.signal });
// or AgentExecutor.execute({ ..., signal }), resumeAfterApproval(..., { signal }),
// or signal: AbortSignal.timeout(30_000)
if (result.finishReason === 'aborted') { /* partial result.messages, result.steps */ }
  • ExecuteOptions.signal, SendOptions.signal (new exported type for createAgent().send), and resumeAfterApproval's executeOptions.signal.
  • The loop checks the signal before each model call and before each tool call. An aborted run resolves with finishReason: 'aborted' and the transcript/steps so far. It emits a typed abort event (abortReason = signal.reason) and then finish. With sessionId + checkpointStore it is checkpointed, and a later execute() with the same sessionId resumes it. Tool calls the run never reached get a cancelled {error} tool result, so the transcript stays valid for the provider on resume. An already-aborted signal returns without calling the provider.
  • Forwarding:
    • Provider: new GenerateOptions.signal, sent as abortSignal to the ai SDK in the single buildCallSettings place. The mock provider rejects promptly, including mid-delay and in stream().
    • Tools: execute(args, { abortSignal }), plus an optional third { abortSignal } argument to sandboxExecute.
    • Built-in http tool: passes the signal to fetch and rejects with an AbortError.
    • createDelegateTool children: inherit the parent's signal.
  • Abort is not a failure. An abort-caused provider rejection is not compacted, not surfaced to the model for another attempt, and does not emit error. retry() never retries an AbortError, and the new RetryOptions.signal stops retries.
  • New exported ExecutionFinishReason type ('stop' | 'length' | 'tool_calls' | 'content_filter' | 'error' | 'awaiting-approval' | 'aborted' | (string & {})) is used for ExecutionResult.finishReason and ExecutionEvent.finishReason. It stays open to any string, so existing code compiles.
  • Docs: a "Cancellation" section in docs/api-overview.md (an AbortController example and an AbortSignal.timeout example) and a README feature bullet.

Why

The audit found that a run could not be cancelled. Without that, a responsive UI (a Stop button) or a hard time limit is impossible.

How verified

  • npx tsc --noEmit: exit 0
  • npm run lint: ✖ 436 problems (0 errors, 436 warnings). No warnings in the new code; one {} as any was removed from sandboxGuard.ts.
  • npm run build && npm run build --workspace=packages/create-loushy-agent: both exit 0
  • npm run test:coverage: Test Files 89 passed (89), All files | 83.83 | 87.17 | 86.2 | 83.83, thresholds met
  • npm run fallow: exit 0. ✓ No issues found, ✓ No code duplication found, ✓ 0 above threshold · maintainability 92.4 (good)
  • npm run docs:verify-snippets: all 5 snippet(s) type-check and run cleanly. The new Cancellation snippets are not in the script's default doc set, so I ran them separately with npx tsx scripts/verify-docs-snippets.ts <extracted-section>.md: all 2 snippet(s) type-check and run cleanly.
  • npm run typecheck and npm run typecheck:server --workspace apps/agent-forge: both clean. No agent-forge files were changed. It has no exhaustive switch over finish reasons or event types: its log-builder map is partial, and the finish event already logs Run finished (aborted).

The new tests are in src/execution/cancellation.test.ts (mock/scripted providers, no network): abort before start; during a model call (scripted provider plus mock provider mid-delay); during a tool call (the tool observes abortSignal.aborted); between steps; between two tool calls in one turn; checkpoint written and resumed with the same sessionId; no compaction/surfacing/error event on an abort-caused rejection; a genuine failure still rejects; delegated child aborted with its parent; retry() with AbortError and with signal; createAgent().send(..., { signal }); mock provider; resumeAfterApproval with signal. There is also an abortSignal pass-through test in aiSdkProvider.test.ts and an http-tool abort test in http.test.ts.

Notes for reviewer

  • Other tickets are being worked on in parallel. My edits in aiSdkProvider.ts are one line in buildCallSettings. In toolCallExecution.ts they are a context field plus passing signal through doExecuteToolCall; the thrown-tool-error catch block is untouched.
  • Cancellation is cooperative. A tool that ignores abortSignal runs to completion; its result is recorded, and the run then ends as aborted before the next call.
  • If the provider ignores the signal and returns a final answer anyway, the run ends normally (stop). The signal is only checked before model and tool calls, as specified.
  • sandboxExecute gets the third { abortSignal } argument only when a signal is set, so existing toHaveBeenCalledWith(args, sandbox) expectations still hold. The sandboxed HTTP transport (sandboxHttpFetch) does not take a signal yet; the http tool's direct execute() path passes it to fetch.
  • I did not add 'aborted' to the provider-level GenerateResult['finishReason'] union, because providers reject on abort rather than returning a result. It is in the run-level ExecutionFinishReason instead.

🤖 Generated with Claude Code

LinuxDevil and others added 2 commits October 1, 2026 09:11
What: `signal?: AbortSignal` on AgentExecutor.execute(), createAgent().send(input, { signal })
and resumeAfterApproval() (via its executeOptions). The loop checks the signal before every
model call and every tool call. An aborted run resolves (does not reject) with
finishReason 'aborted' and the transcript/steps so far, emits a typed `abort` event (with
`abortReason`) followed by `finish`, and is checkpointed when sessionId + checkpointStore are
set, so a later execute() with the same sessionId resumes it. Tool calls the run never reached
get a cancelled {error} result so the transcript stays valid for the provider. An
already-aborted signal returns without calling the provider.

The signal is forwarded to the provider (new GenerateOptions.signal, passed to the ai SDK as
`abortSignal`; the mock provider rejects promptly, including mid-delay), to tools as
`execute(args, { abortSignal })` (and as a third `{ abortSignal }` argument to
sandboxExecute), to fetch in the built-in http tool, and to delegated child agents.
A rejection caused by the abort is the abort, not a failure: it is not compacted or surfaced
to the model for another attempt, and retry() never retries an AbortError (new
RetryOptions.signal also stops retries).

Adds an exported ExecutionFinishReason type (known reasons incl. 'awaiting-approval' and
'aborted', still open to any string) used by ExecutionResult and ExecutionEvent.

Why: runs could not be cancelled, which blocks responsive UIs and timeouts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	src/execution/AgentExecutor.ts
@LinuxDevil
LinuxDevil merged commit f27ecb3 into main Oct 1, 2026
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