UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

175 lines (144 loc) • 7.14 kB
--- title: Agent Cancellation description: Cancel a running WorkflowAgent from the outside using AbortSignal and a durable stop hook. type: guide summary: Cancel a running WorkflowAgent cooperatively with AbortController so the model stream stops and the workflow can return a clean status. --- <CopyPrompt text="Add cancellation to this AI SDK WorkflowAgent. For hard cancellation, expose a server route that receives `runId` and calls `getRun(runId).cancel()` from `workflow/api`. For graceful cancellation, define `stopHook` with `defineHook()` from `workflow`, create it with a stable token such as the workflow run ID, and race `WorkflowAgent.stream()` against the stop hook using `Promise.race`. Pass an `AbortController` signal to the agent, forward the tool execution signal to cancellable I/O, and wait for the agent branch to settle after aborting before returning a stopped status. Wire the UI Stop button to the route that resumes the hook or falls back to `getRun(runId).cancel()`, and verify stale run IDs are handled." /> Cancel a running agent from the outside through a **Stop** button in a chat user interface (UI), an admin cancellation endpoint, or a timeout fallback. ## Pattern Create an `AbortController` in the workflow and race the agent (passing its signal) against a stop hook. When the hook fires, call `controller.abort()`, then wait for the agent branch to observe the signal and settle before the workflow returns. The signal cancels the underlying model stream and is passed to tool execution; each tool must forward it to cancellable I/O. The workflow return value records whether it completed or stopped; read that value through the run API or persist it in application state. {/* @skip-typecheck: requires AI SDK 7 and @ai-sdk/workflow */} ```typescript lineNumbers import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow"; import { isStepCount, tool, type ModelMessage, type ToolExecutionOptions, } from "ai"; import { defineHook, getWritable, getWorkflowMetadata } from "workflow"; import { z } from "zod"; export const stopHook = defineHook({ schema: z.object({ reason: z.string().optional() }), }); async function searchWeb( { query }: { query: string }, { abortSignal }: ToolExecutionOptions<unknown>, ) { "use step"; const response = await fetch( `https://api.example.com/search?q=${encodeURIComponent(query)}`, { signal: abortSignal }, // [!code highlight] ); return response.json(); } async function analyzeData( { topic }: { topic: string }, { abortSignal }: ToolExecutionOptions<unknown>, ) { "use step"; const response = await fetch("https://api.example.com/analyze", { method: "POST", body: JSON.stringify({ topic }), signal: abortSignal, // [!code highlight] }); return response.json(); } export async function stoppableAgent(messages: ModelMessage[]) { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); const controller = new AbortController(); // [!code highlight] const hook = stopHook.create({ token: `stop:${workflowRunId}` }); const agent = new WorkflowAgent({ model: "spacexai/grok-4.6", instructions: "You are a research assistant. Search and analyze data as needed.", tools: { searchWeb: tool({ description: "Search the web for information", inputSchema: z.object({ query: z.string() }), execute: searchWeb, }), analyzeData: tool({ description: "Analyze a piece of data", inputSchema: z.object({ topic: z.string() }), execute: analyzeData, }), }, }); const agentPromise = agent.stream({ messages, writable: getWritable<ModelCallStreamPart>(), abortSignal: controller.signal, // [!code highlight] stopWhen: isStepCount(15), }); const outcome = await Promise.race([ agentPromise.then((result) => ({ type: "complete" as const, messages: result.messages, })), hook.then(({ reason }) => ({ type: "stop-requested" as const, reason, })), ]); if (outcome.type === "stop-requested") { controller.abort(outcome.reason); // [!code highlight] await agentPromise; // Wait until the losing branch has stopped. // [!code highlight] return { type: "stopped" as const, reason: outcome.reason }; } return outcome; } ``` ### API route to trigger stop ```typescript lineNumbers import { stopHook } from "@/workflows/stoppable-agent"; export async function POST( request: Request, { params }: { params: Promise<{ runId: string }> } ) { const { runId } = await params; const { reason } = await request.json(); await stopHook.resume(`stop:${runId}`, { reason: reason || "User requested stop", }); return Response.json({ success: true }); } ``` ### Client stop button ```tsx lineNumbers "use client"; export function StopButton({ runId }: { runId: string }) { const handleStop = async () => { await fetch(`/api/chat/${runId}/stop`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "User clicked stop" }), }); }; return ( <button type="button" onClick={handleStop}> Stop Agent </button> ); } ``` ## How it works 1. The workflow creates an `AbortController` when it starts. 2. The workflow creates a hook with the token `stop:${workflowRunId}`. 3. `Promise.race` runs the agent stream and the stop hook concurrently. 4. The agent receives `controller.signal`. When aborted, the signal cancels the active model stream, propagates to tool execution, and prevents another model step from starting. Tool implementations must forward it to operations such as `fetch` that support cancellation. 5. When the stop API resumes the hook, the race reports a stop request. The workflow aborts the controller and awaits `agentPromise`, so it does not return while that branch is still running. 6. After the agent branch settles, the workflow returns a `stopped` result. That return value is not written to the model-call stream automatically; the application can read `run.returnValue` or persist the status separately. ## Adapting this - **Add a timeout**: Race a third `sleep()` promise to stop automatically after a deadline. - **Audit logging**: Include a `reason` field in the stop schema to record who stopped the agent and why. - **Cross-process**: The hook token is deterministic, so any process can call `stopHook.resume()` with the run ID. - **Step limits**: Combine the pattern with `stopWhen: isStepCount(...)` to cap execution without a manual stop. ## Key APIs - [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a type-safe hook for the stop signal. - [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Provides the run ID for deterministic hook tokens. - [`getWritable()`](/docs/api-reference/workflow/get-writable): Stores durable model-call output from the agent. - [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK's durable agent that applies the abort signal to model calls and replaces `DurableAgent`.