Skip to content

Cancellation ​

Cancelling a run should stop the model, not just stop listening to it. Since 0.10.6 it does: the abort signal (or cancellation token) you pass to invoke(...) travels all the way down to the HTTP request of the model call, and the run ends as cancelled instead of failing with a model error.

What's new in 0.10.6

Before 0.10.6, the signal reached the agent loop and no further. The loop walked away, but the provider connection stayed open and the model kept generating (and billing) until it finished. A cancelled model call also surfaced as a model error. Both are fixed — see What changed in 0.10.6.

When to use this ​

  • A user presses Stop in a chat UI and you want the tokens to stop immediately.
  • A request handler is aborted (req.on("close"), a client disconnect) and the agent run it started should not keep running.
  • A parent run is cancelled and its sub-agents should stop with it.
  • You enforce your own deadline and want the in-flight model request hung up, not abandoned.

Cancel a run ​

Pass a cancellationToken in the InvokeConfig. The simplest token is a standard AbortSignal:

ts
import { createSmartAgent, createProvider, fromNativeProvider } from "@cognipeer/agent-sdk";

const agent = createSmartAgent({
  model: fromNativeProvider(
    createProvider({ provider: "openai", apiKey: process.env.OPENAI_API_KEY! }),
    { model: "gpt-4o" },
  ),
});

const controller = new AbortController();

const run = agent.invoke(
  { messages: [{ role: "user", content: "Write a long report on container networking." }] },
  {
    stream: true,
    cancellationToken: controller.signal,
    onStream: (chunk) => process.stdout.write(chunk.text),
  },
);

// Later — a Stop button, a closed socket, a deadline:
setTimeout(() => controller.abort(), 3_000);

const result = await run; // resolves (does not reject) with the run marked as cancelled
console.log(result.state?.ctx?.__cancelled);
// { stage: "agent", reason: "aborted", timestamp: "2026-10-11T10:15:03.412Z" }

The three token spellings ​

A host picks whichever cancellation primitive its own stack already has. All three reach the provider:

You passTypeHow the SDK detects itreason reported
controller.signala real AbortSignalsignal.aborted (and its abort event)"aborted"
{ isCancellationRequested }CancellationTokenLike (VS Code style), optionally with onCancellationRequested(listener)the flag is sampled while a call is in flight; a listener, when present, fires immediately"cancelled"
{ aborted }a bare { aborted: boolean } object that is not a real signalthe flag is sampled while a call is in flight"aborted"

fetch only understands a real AbortSignal, so the SDK turns the other two into one for each model call. A token that only exposes a flag is sampled every 100 ms while the call is in flight (the timer is released as soon as the call ends and never keeps a finished process alive). When you already pass a single real AbortSignal it is used as-is — no wrapper, no polling.

ts
// A flag-only token (no AbortController in your stack):
const token = { isCancellationRequested: false };
const run = agent.invoke(input, { stream: true, cancellationToken: token });
token.isCancellationRequested = true; // the model request is hung up within ~100 ms

What happens when you cancel ​

  1. The HTTP request is cancelled. The provider call carries the run's signal, so fetch aborts it. This applies to OpenAI (Chat Completions and Responses), Azure, Anthropic, Bedrock and Vertex, in both invoke and stream.
  2. An open stream is closed. The SSE reader is cancelled, not merely released, so the provider stops generating.
  3. A retry backoff ends. A call that is cancelled while the provider is waiting to retry a 429 / 5xx does not sit out the delay and does not start another attempt.
  4. The run ends as cancelled. invoke(...) resolves; it does not throw a model error.

What you observe on the run:

SurfaceValue
result.state.ctx.__cancelled{ stage: "agent", reason: "aborted" | "cancelled", timestamp }
onEvent{ type: "cancelled", stage: "agent", reason }
onProgress{ stage: "agent", message: "Cancelled", detail: { reason } }
Tracean ai_call event labelled Assistant Cancelled with status skipped (not error, so the session does not flip to error)
sessionEnd plugin hookstatus: "cancelled"

What is kept and what is dropped ​

Situation at the moment of cancellationResult
The model had streamed some textThat text is kept as the assistant turn in result.messages.
The model had said nothing yetNothing is appended — no empty assistant message.
A tool call was still being streamedIt is dropped: the tool never runs and the transcript never holds an unanswered tool_call (an unanswered call would make the next send fail with a provider 400).
A stream was cut by the abortIt is never passed off as a finished message — parseSSEStream(body, signal) throws on an aborted signal instead of ending quietly as if the body were complete.
Post-model guardrails and checksSkipped. The loop does not start another model call.

Both createAgent(...) and createSmartAgent(...) behave this way; the smart driver does not begin a second model call after a cancellation.

Where cancellation is checked ​

Besides the in-flight model request, the loop checks for cancellation between stages: loop, before_guardrails, before_agent, before_tools — and again before each tool starts (stage: "tools"). Every check reports the same ctx.__cancelled shape with the stage that noticed it.

timeoutMs in InvokeConfig sets a run deadline that is checked at the loop stages above (not before each individual tool) and reports reason: "timeout". It does not hang up a model request that is already in flight — pass a cancellationToken for that.

A running tool is not interrupted

The loop checks for cancellation before each tool starts, but a tool whose func is already executing runs to completion — the SDK cannot kill it, and createTool(...) hands func only the call's arguments. Give a long-running tool its own bound, for example fetch(url, { signal: AbortSignal.timeout(10_000) }), so one slow call cannot hold a cancelled run open.

Sub-agents ​

Cancelling a parent run cancels its in-flight children: the parent passes its cancellation token (or abort signal) to each child run. See Sub-Agents & Delegation.

Using a provider directly ​

Outside an agent you can pass signal to a provider yourself. ChatCompletionRequest has an optional signal, it is never serialised into the request body, and an aborted call rejects with an AbortError:

ts
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);

for await (const chunk of provider.completeStream({
  model,
  messages,
  signal: controller.signal,
})) {
  // A `break` or `return` in this loop also closes the stream.
}

See Native Providers → Cancellation for the provider-level contract, and Adapters & Models for how fromNativeProvider and fromLangchainModel link the signal.

LangChain models ​

fromLangchainModel(...) gives LangChain a real signal for every invoke and stream call. LangChain knows nothing of a cancellation token, so a token that only exposes a flag is converted to a signal first.

Handling a cancelled result ​

ts
const result = await agent.invoke(input, { stream: true, cancellationToken: controller.signal });

const cancelled = result.state?.ctx?.__cancelled;
if (cancelled) {
  // Not an error: the caller ended the run.
  console.log(`cancelled at ${cancelled.stage} (${cancelled.reason})`);
  // result.content / result.messages hold whatever had been said so far.
} else {
  console.log(result.content);
}

The transcript in result.messages is valid after a cancellation — it holds no unanswered tool_call — so it can be sent back to the model as history on the next turn.

Troubleshooting ​

The model kept streaming after I aborted. Check your version (0.10.6 or later) and that the signal is passed as cancellationToken in the InvokeConfig of invoke(...). A signal you only use in your own code never reaches the SDK.

I get an AbortError thrown out of invoke. Inside an agent run a cancelled model call is reported as cancelled, not thrown. An AbortError you see comes from calling a provider directly with signal, or from your own tool code that propagates the signal.

reason is "cancelled" although I used an AbortController. "aborted" is reported when the run was given an AbortSignal (or a { aborted } object) that fired; "cancelled" comes from an { isCancellationRequested } token. Check that you passed controller.signal itself rather than a wrapper object.

The run reports timeout, but the request kept going. timeoutMs is a between-stages deadline. Abort a cancellationToken from your own timer to hang up an in-flight request.

A tool kept running after cancel. See the warning above: a tool that is already executing is not interrupted. Bound long-running tool calls with their own timeout.

What changed in 0.10.6 ​

  • Cancelling a run now stops the model. fromNativeProvider used to drop the signal, so fetch never saw it. The request now carries the call's signal in invoke and stream; ChatCompletionRequest gains an optional signal for direct provider use; fromLangchainModel hands LangChain a real signal.
  • A cancelled model call is reported as cancelled, not thrown. No Assistant Error trace and no throw; the run ends with ctx.__cancelled, a cancelled event and an Assistant Cancelled trace event.
  • Streams are closed on early exit (the SSE reader is cancelled, not only released), and retry backoff ends the moment the call is cancelled.

See the Agent SDK release notes for the full list.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub