Tools

Upgrade to Tasks

Move workflow tools, agent calls, clients, hooks, and evals from background tasks to tasks.

This release replaces background tasks with tasks. Every change below breaks the previous API. Work through the sections that apply to your agent. A workflow tool that uses none of execution: "background", dismissible, or ctx.agent keeps its code: execute(input, ctx) keeps its signature, and ctx.abortSignal and ctx.callId are where they were. One behavior changes for every execute tool: a steering message now aborts its ctx.abortSignal. See Drop dismissible. Type-checking flags most of the code changes, such as the removed execution and dismissible options and the old ctx.agent call.

Replace execution: "background" with task(input, ctx)

Rename execute to task and remove execution. The body doesn't change:

 export default defineWorkflowTool({
   description: "Deploy a service.",
   inputSchema: z.object({ service: z.string() }),
-  execution: "background",
-  async execute({ service }, ctx) {
+  async task({ service }, ctx) {
     "use workflow";
     return await deploy(service, ctx.abortSignal);
   },
 });

defineWorkflowTool rejects execution with "execution" was replaced by task(). Define task(input, ctx) to run each call as a task. A task body gets the same WorkflowToolContext as an execute body, and steering never aborts a task's abortSignal. An execute tool is an ordinary tool call: the turn waits for its result. See Choose how a call runs.

A tool that the model should be able to send more input while it works, such as a plan it revises on request, defines serve(receive, ctx) instead.

Drop dismissible: a new message stops an execute call

ctx.ask no longer accepts dismissible. A steering message now aborts the ctx.abortSignal of each execute call the turn waits on, which withdraws every question the call asked, as dismissible: true did:

-const answer = await ctx.ask({ prompt, dismissible: true });
+const answer = await ctx.ask({ prompt });

To keep a question open through new messages, ask it from a task instead. Other work that receives ctx.abortSignal, such as a step or a ctx.agent send, stops too. The call settles with what the body returns, or with { interrupted: true } if the body rejects, and the model reads the message next.

A withdrawn question resolves as { status: "cancelled" } instead of dismissed, and the input.resolved outcome "dismissed" is now "cancelled". Update clients and channels that match on the old value. See Pass signals to the work.

Open agent sessions with ctx.agent(name)

ctx.agent(name, { message, agentId }), which returned the agent's output, is now ctx.agent(name), which returns a session. Send a message and read the turn's result:

-const review = await ctx.agent("reviewer", { message: plan });
+const response = await ctx.agent("reviewer").send(plan, { signal: ctx.abortSignal });
+const { message: review, status } = await response.result();
+if (status === "failed") throw new Error("The review failed.");

agentId is removed. Each ctx.agent(name) call returns a new session, and later send calls on the same handle continue its conversation until the workflow run finishes. See Delegate work. The workflow tool's generated programs keep their ctx.agent(name, { message, outputSchema? }) capability.

Continue agents by taskId

Every agent tool is now a serve tool, so every agent call is a resumable task. The model's agent tools take taskId instead of agentId:

-{ "message": "Check the Q2 numbers too.", "agentId": "…" }
+{ "message": "Check the Q2 numbers too.", "taskId": "researcher-7k2m9q" }

An agent call returns a receipt at once, and the agent's reply arrives later in a task.result message. Update instructions, prompts, and evals that mention agentId or expect the reply as the tool result. agentRouter() and the workflow tool now run each call as a task too.

The removed codes AGENT_BUSY, AGENT_MISMATCH, AGENT_UNREACHABLE, and AGENT_INVOCATION_NOT_ADMITTED have no replacement. A taskId that names no unfinished task of that tool fails with UNKNOWN_TASK, and a call past the limit of 32 working tasks fails with TOO_MANY_TASKS.

Remove background delivery options

Results reach the model one way: a task.result message at a step boundary. Remove these:

  • taskDeliveryPolicy and delivery cohorts.
  • The tasks option of session.cancel(). session.cancel() now cancels the turn, the execute calls it waits on, and every working task; it still accepts turnId and signal.

The model no longer sees [Task state] or [Agents] notes, prose task notifications, <eve-empty-delivery/>, or result turns. A [Tasks] note lists its working and idle tasks instead. The BACKGROUND_TASK_FAILED and BACKGROUND_TASK_CANCELLED codes are removed; a failed task reports its error in task.settled and in its task.result block.

Read task events instead of subagent.* events

The subagent.called, subagent.started, subagent.completed, and subagent.event events are removed. Use these instead:

EventReplacesData
task.startedsubagent.calledtaskId, callId, turnId, name, kind
task.settledsubagent.completedtaskId, callId, turnId, name, kind, status, and output or error unless cancelled
agent.startedsubagent.startedcallId, turnId, taskId, name, sessionId, streamPath

Follow a child's own events, which subagent.event used to relay, on its stream: session.streamSubagent() is now session.agent(started).stream(), and it takes the agent.started event.

 for await (const event of session.stream()) {
-  if (event.type !== "subagent.called") continue;
+  if (event.type !== "agent.started") continue;
-  for await (const childEvent of session.streamSubagent(event)) {
+  for await (const childEvent of session.agent(event).stream()) {

Hooks subscribe the same way:

 export default defineHook({
   events: {
-    async "subagent.completed"(event, ctx) {
+    async "task.settled"(event, ctx) {
+      if (event.data.status !== "completed") return;

The eval assertion t.calledSubagent(name, { status }) keeps its API and reads task events.

Handle open turns in clients

A turn doesn't end while tasks are working, so one turn can span a wait. The stream reports each wait with the new turn.waiting event, which carries the open turn's turnId:

  • Held turns. When the model ends its text while its tasks work, and when task_wait has no result yet, the stream emits turn.waiting. The next step.started for the same turnId means the turn resumed. In a root session, the text before the wait completes as a normal message.completed; in child sessions and a schedule's sessions, that step reports finishReason: "tool-calls".
  • Questions and sign-ins inside a call no longer end the turn. Previously, a question or sign-in from inside a running call, such as a workflow tool's ctx.ask(), ask_question, or a subagent's question, emitted turn.completed and session.waiting in the middle of the turn. It now emits input.requested or authorization.required, then turn.waiting, and the turn resumes under the same turnId after the answer. The session you answer on emits input.resolved for every question it routes an answer to. A tool approval the turn itself requests still ends the turn with turn.completed and session.waiting.
  • turn.completed comes only when the turn really ends, and session.waiting always means the turn has ended.

The TypeScript client's send(...).result() reads past turn.waiting and returns the final reply of a held turn, not the text written before the wait. It stops at turn.waiting only while a question is pending, and returns status: "waiting" with that question; respond() then reads the same turn to its end. A custom client that ends a response at turn.completed or session.waiting mid-turn, or that shows the first completed message as the reply, should follow the same rule. See Aggregate a turn.

Task results aren't stream events. Background task results used to appear as message.received with data.kind: "execution.background_task"; message.received no longer has kind, and a task result reaches only the model. Read each call's outcome from task.settled instead.

The message stream version is now 26. The TypeScript client accepts versions 21 through 26. A client that accepts only versions up to 25 fails the stream with an unsupported-version error. See Turns wait for their tasks.

Upgrade remote agents before their callers

The remote agent protocol is now version 2. Deploy each remote agent before the deployments that call it. A remote agent on this release still serves callers on eve 0.66 through 0.68, which speak protocol 1. It runs their turns, sends each result to their callback, and accepts their follow-up and reset requests. It also sends the remote agent's tool approvals and sign-in requests to the caller, and accepts the caller's answers, the same way a 0.66–0.68 remote agent does. As in 0.66–0.68, this covers agents the caller's model calls, which run as background tasks. When the caller's own workflow tool code calls an agent, its approvals and sign-in requests stay on the remote agent's channel.

Two things differ from a remote agent on the caller's own release:

  • The caller's channel doesn't show the remote agent's live tool activity nested under the call.
  • ctx.ask() in the remote agent returns unavailable, because protocol-1 callers don't grant the remote agent input. That's unchanged from 0.66–0.68.

Each protocol-1 create logs serving a remote agent protocol 1 caller with the caller's origin.

Upgrade a caller after every remote agent it calls. A caller on this release fails a call at start, with an error that names both versions, when the remote agent's deployment speaks protocol 1.