Skip to content

🆕 WorkflowAgent (@ai-sdk/workflow)#12165

Open
gr2m wants to merge 98 commits intomainfrom
gr2m/durable-agent
Open

🆕 WorkflowAgent (@ai-sdk/workflow)#12165
gr2m wants to merge 98 commits intomainfrom
gr2m/durable-agent

Conversation

@gr2m
Copy link
Copy Markdown
Collaborator

@gr2m gr2m commented Jan 30, 2026

Create a new @ai-sdk/workflow package that exports WorkflowAgent, which will be the successor of DurableAgent

ToolLoopAgent parity plan

The underlying streamText in core already supports all 6 callback types. The gap is that WorkflowAgent doesn't accept or pass them through. However, WorkflowAgent doesn't call streamText directly — it uses streamTextIterator → doStreamStep → streamModelCall. So the callbacks need to be threaded through that chain.

Phase 1: Wire missing callbacks through WorkflowAgent API ✅ (#14036)

  1. Add the 4 missing callback types to WorkflowAgentOptions and WorkflowAgentStreamOptions interfaces
  2. Add mergeCallbacks utility (extracted from ToolLoopAgent pattern)
  3. Pass callbacks through streamTextIterator to doStreamStep (which uses streamModelCall)
  4. Emit callbacks at the right points in the iterator loop

Unblocked 14 of 16 GAP tests. 2 remain as it.fails() to track event shape parity (see below).

Remaining work from Phase 1: Align callback event shapes with ToolLoopAgent. WorkflowAgent's callback events are simpler than ToolLoopAgent's. ToolLoopAgent events (defined in core-events.ts) include callId, provider, modelId, stepNumber, messages, abortSignal, functionId, metadata, experimental_context, typed toolCall with TypedToolCall<TOOLS>, and durationMs on tool call finish. WorkflowAgent events currently only provide a subset (e.g., onToolCallStart only has toolCall with a plain ToolCall type). Once the event shapes converge, the callback types could be unified as shared AgentOnStartCallback, AgentOnStepStartCallback, etc. instead of separate WorkflowAgent* and ToolLoopAgent* types.

Phase 2: Add prepareCall support ✅ (#14037)

  1. Add prepareCall to WorkflowAgentOptions
  2. Call it in stream() before the iterator, similar to ToolLoopAgent's prepareCall()

Unblocked 1 GAP test.

Remaining work from Phase 2: ToolLoopAgent's prepareCall also supports stopWhen, activeTools, and experimental_download in its input/output types — these are not yet in WorkflowAgent's PrepareCallOptions/PrepareCallResult. Additionally, ToolLoopAgent supports typed CALL_OPTIONS that flow through prepareCall as options — WorkflowAgent doesn't have this concept.

Phase 3: Add workflow serialization support to all provider models ✅ (#13779)

Adds WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE to all 59 provider model classes (language, image, embedding, speech, transcription, video). Adds serializeModel() and deserializeModelConfig() helpers to @ai-sdk/provider-utils:

  • serializeModel resolves config.headers() at serialization time so auth credentials survive the step boundary as plain key-value objects
  • deserializeModelConfig wraps plain-object headers back into a function on deserialization

Makes headers optional in all provider config types so deserialized models work without pre-configured auth. Includes documentation for third-party provider authors.

Remaining work from Phase 3: async headers providers. Four providers have async getHeaders which can't be resolved synchronously at serialization time. These need per-provider handling or a model factory function workaround:

  • Gateway — async OIDC token resolution (AI_GATEWAY_API_KEY env var fallback)
  • Amazon Bedrock (anthropic subprovider) — async SigV4 credential loading (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars)
  • KlingAI — async JWT generation from KLINGAI_ACCESS_KEY/KLINGAI_SECRET_KEY env vars
  • Google Vertex — async Resolvable headers (GOOGLE_VERTEX_API_KEY env var for express mode)

Phase 4: Add needsApproval support ✅ (#14084)

  1. Before executing a tool, check tool.needsApproval (boolean or async function)
  2. If approval needed, pause the loop and return pending tool calls (like client-side tools)
  3. Handle approval resumption: collect tool-approval-response parts, execute approved tools, create denial results
  4. Write tool results and step boundaries to the UI stream so tool parts transition to output-available state and convertToModelMessages produces correct message structure for multi-turn conversations

This unblocked 2 GAP tests.

Phase 5: Telemetry integration listeners

  1. Wire through telemetry integration listeners from experimental_telemetry
  2. Call them alongside agent callbacks at each lifecycle point

This unblocks 3 GAP tests.

Phase 6: Clean up duplication

  1. Extract shared mergeCallbacks utility ✅ (done in feat(workflow): add onStart, onStepStart, onToolCallStart, onToolCallFinish callbacks #14036 — moved to ai/internal, used by both ToolLoopAgent and WorkflowAgent)
  2. Remove duplicate filterTools (use one location)
  3. Replace getErrorMessage with import from @ai-sdk/provider-utils
  4. Remove safeParseInput if unused
  5. Simplify prepareStep override application in streamTextIterator

Future work

Done

  • Separate UIMessageChunk conversion from model streaming (refactor: separate UIMessageChunk conversion from model streaming #13780)
    Extracts UIMessageChunk conversion from doStreamStep into a standalone utility, making the model streaming layer independent of UI concerns. doStreamStep returns raw LanguageModelV4StreamPart[] chunks; UIMessageChunk conversion is a separate, optional step. writable becomes optional in WorkflowAgentStreamOptions — when omitted, the agent streams ModelMessages only. Follows streamText's toUIMessageStream() pattern.
  • Use experimental_streamModelCall in doStreamStep (refactor: use experimental_streamModelCall in doStreamStep #13820)
    Replace doStreamStep internals with experimental_streamModelCall. Eliminates ~300 lines of duplicated stream transformation, gains tool call parsing/repair, retry logic, and Experimental_ModelCallStreamPart stream types.
  • Export mergeAbortSignals from ai/internal (refactor: use shared mergeAbortSignals from ai/internal in WorkflowAgent #13616)
    Exports the existing mergeAbortSignals utility from ai/internal and replaces the manual ~25-line abort signal + timeout merging code in WorkflowAgent with the shared utility. Uses AbortSignal.timeout() instead of manual setTimeout + AbortController, matching how generateText/streamText handle the same concern.
  • Wire missing callbacks (Phase 1) (feat(workflow): add onStart, onStepStart, onToolCallStart, onToolCallFinish callbacks #14036)
    Adds experimental_onStart, experimental_onStepStart, experimental_onToolCallStart, experimental_onToolCallFinish callbacks to WorkflowAgent. Extracts mergeCallbacks into ai/internal as shared utility used by both ToolLoopAgent and WorkflowAgent. Also fixes sideEffects: false breaking workflow step discovery and replaces resolveLanguageModel from ai/internal with gateway from ai to fix Next.js webpack resolution in step bundles.
  • Add prepareCall support (Phase 2) (feat(workflow): add prepareCall callback #14037)
    Adds prepareCall callback to WorkflowAgentOptions, called once before the agent loop to transform model, instructions, generation settings, etc. tools excluded from return type since they're bound at construction time for type safety.

No longer pursued

Notes

  • Resumption: the client-side is expected to specify which chunk to resume from at the workflow stream level (the V4StreamPart). If that maps 1-1 to UIMessageChunk, or if there's a way to map the correct resumption index from the number of UIMessageChunk parts that the client-side has received, that's what needs to be considered.
  • repairToolCall not serializable across step boundaries. ToolCallRepairFunction is a function and can't cross the 'use step' serialization boundary. Left out of WorkflowAgent for now — experimental_streamModelCall handles repair internally when called outside a step boundary.
  • abortSignal serialization. AbortSignal objects can't be serialized across step boundaries. The Workflow team is working on adding serialization support for abort signals.
  • Tools validation: because of serialization/deserialization needed for step functions, we use validation capabilities and transformations of libraries like zod. Serialization is lossy.

Related issues

@vercel-ai-sdk vercel-ai-sdk bot added the maintenance CI, internal documentation, automations, etc label Jan 30, 2026
@gr2m gr2m changed the title 🚧 DurableAgent 🚧 DurableAgent - DO NOT MERGE Jan 30, 2026
@gr2m

This comment was marked as resolved.

@gr2m gr2m marked this pull request as ready for review January 30, 2026 19:33
Copy link
Copy Markdown
Contributor

@vercel-ai-sdk vercel-ai-sdk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHALL NOT PASS

@gr2m
Copy link
Copy Markdown
Collaborator Author

gr2m commented Jan 30, 2026

The snapshot build published (https://github.com/vercel/ai/actions/runs/21530986703/job/62046593364) published @ai-sdk/durable-agent@0.0.1 instead of the usual snapshot versions. That was not planned.

@KaiKloepfer
Copy link
Copy Markdown

@gr2m I did some work on improving this from the workflow side, might be relevant to you? vercel/workflow#928 Still not full compatibility, but we got stuck on the same issues when trying to port a v6 app to use workflow.

@rovo89
Copy link
Copy Markdown

rovo89 commented Feb 12, 2026

@gr2m I think this is the right approach, avoiding a lot of compatibility layers. However, it seems that the code is copied more or less 1:1 from the workflow code, which has simplified lots of things. For example, OpenAI's web_search doesn't work for me because toolsToModelTools() is extremely basic. I'm hoping this will be much closer to the original ToolLoopAgent.

I would offer to help, but I assume you already have own ideas how things should work (and I know little about the AI SDK internals). Anyway, if I can do anything, I'm happy to help.

@gr2m
Copy link
Copy Markdown
Collaborator Author

gr2m commented Feb 12, 2026

@KaiKloepfer thanks will have a look!

@rovo89 I'm focused on #12381 right now, please feel free to send PRs for exploration of different approaches.

@rovo89
Copy link
Copy Markdown

rovo89 commented Feb 12, 2026

My current attempt is to simpy use ToolLoopAgent in a step function. 🙈

export async function chat(writable: WritableStream<UIMessageChunk>, messages: UIMessage[]) {
  'use step';
  const agent = new ToolLoopAgent({...});
  const stream = await createAgentUIStream({
    agent,
    uiMessages: messages,
  })
  await stream.pipeTo(writable)
}

Streaming works fine and I can use it exactly like I'm used to.

DurableAgent has quite some limitations:

  • Many details are implemented as very simplified stubs, such as the tool preparation which prevents using OpenAI provider tools.
  • The default downloader doesn't work because fetch is blocked (need to use their fetch step function instead) and supportedUrls is empty.
  • Model has to be provided as step function, therefore requires extra layer / provider wrapper packages. Custom provider registries are harder to use.
  • Message metadata is a lot harder to add, requires disabling default start/finish chunks and sending them separately.
  • ...

But of course, it has benefits. As far as I understood:

  • Can resume multi-step calls instead of repeating from scratch. Not sure what happens if an error occurs mid-step (e.g. network failure) - will probably run that step again, but what about the already sent chunks?!?
  • Tool calls are steps, so they can also be retried.
  • Hooks can simplify human-in-the-loop / tool approvals.
  • More stable environment for the loop controller.

Some ideas how similar features could be achieved in (a subclass of) ToolLoopAgent:

  • Read back the stream to reconstruct the message chunks so far and continue from there. Or more abstract: Needs a way to "preload" the loop with the previously recorded intermediate step results and continue with the next step.
  • AFAIU, calling a step function from another step has no special meaning, so no retries etc. That's only when they're called from a workflow function. Similar for hooks. Generally, ToolLoopAgent and the functions it calls are somehow orchestrating (like workflow functions), but they're far from just stitching steps together. That's why I think it makes sense to run them in a step function, but they could benefit from sub-steps. That needs further thoughts.
  • Of course, this assumes that the setup is still the same on the next retry, like all tools being defined in the same way, which seems to be guaranteed in the workflow VM. Then again, how big is the risk that this happens accidently and how big could the damage be?

@gr2m
Copy link
Copy Markdown
Collaborator Author

gr2m commented Mar 14, 2026

I want to try two different approaches in separate PRs against gr2m/durable-agent

  1. Try to refactor streamText() itself so that it works in a workflow context.
  2. Refactor streamText() to export lower-level orchestration code which then can be used by DurableAgent

I think 1. won't be possible for several reasons but I want to see how far I can get.

For the providers, as a start I want to re-export all first-party providers with the symbols needed for workflow step serialization/deserialization and the "use step" directive in doStream()

image

felixarntz

This comment was marked as resolved.

gr2m and others added 5 commits April 9, 2026 14:04
The globalThis.AI_SDK_DEFAULT_PROVIDER accesses are already properly
typed via declare global in src/global.ts. Restore @ts-expect-error
for the experimental videoModel access (preferred over @ts-ignore).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These files were added during development and should not be merged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
experimental_output is deprecated in the AI SDK. Use the non-experimental
output parameter and property name instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gr2m and others added 4 commits April 9, 2026 15:26
The internal entry point re-exports resolveLanguageModel but didn't
have the globalThis type augmentation in scope, causing DTS build
failures for AI_SDK_DEFAULT_PROVIDER.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Merges two optional callbacks into one that calls both sequentially.
* The first callback (from constructor/settings) runs before the second (from method).
*/
export function mergeCallbacks<
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to utils

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// private, making it invisible to external type checks. The static
// WORKFLOW_SERIALIZE method has runtime access to the field regardless.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function serializeModel(inst: any): {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be typed to languagemodelv4 since the docs say it is for a language model (also should the param be called model)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.config is a private implementation detail. We have access to it at runtime but typescript has no way to express that. Hence the any.

* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deserializeModelConfig<T>(config: T): T {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternative: deserialize model that accepts model class prototype and config and then returns the model

(seems better if possible because then serializeModel / deserializeModel are clear opposites)

Copy link
Copy Markdown
Collaborator Author

@gr2m gr2m Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c11b83e — added deserializeModel(ModelClass, options) as the symmetric opposite of serializeModel, and updated all provider model classes to use it.

return result;
}

function isSerializable(value: unknown): boolean {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this model specific?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes some models are not serializable because of async headers methods

I mentioned this in the PR description

Remaining work from Phase 3: async headers providers. Four providers have async getHeaders which can't be resolved synchronously at serialization time. These need per-provider handling or a model factory function workaround:

  • Gateway — async OIDC token resolution (AI_GATEWAY_API_KEY env var fallback)
  • Amazon Bedrock (anthropic subprovider) — async SigV4 credential loading (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars)
  • KlingAI — async JWT generation from KLINGAI_ACCESS_KEY/KLINGAI_SECRET_KEY env vars
  • Google Vertex — async Resolvable headers (GOOGLE_VERTEX_API_KEY env var for express mode)

The workflow team is looking into support of async serialize methods which will unblock support for these providers.

gr2m and others added 8 commits April 10, 2026 09:07
Resolved conflict in tool-loop-agent.ts: adopted main's rename of
mergeCallbacks to mergeListeners throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces `deserializeModel(ModelClass, options)` which accepts a model
class constructor and the serialized `{ modelId, config }` payload,
creating a clean `serializeModel` / `deserializeModel` symmetry.

Updated all 58 provider model classes to use the new helper in their
`WORKFLOW_DESERIALIZE` implementations. `deserializeModelConfig` is kept
exported for the one non-standard case (GoogleGenerativeAIImageModel
with a 3-arg constructor) and backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… support (#14340)

## Background

The `WorkflowChatTransport` class was implemented in `@ai-sdk/workflow`
but not exported, making it unavailable to consumers. This transport is
needed for `useChat` to enable automatic stream reconnection in
workflow-based chat apps — handling network failures, page refreshes,
and function timeouts by reconnecting to the workflow's stream endpoint.

Reference:
[DurableChatTransport](https://useworkflow.dev/docs/api-reference/workflow-ai/workflow-chat-transport)

## Summary

- Export `WorkflowChatTransport` class and related types
(`WorkflowChatTransportOptions`, `SendMessagesOptions`,
`ReconnectToStreamOptions`) from `@ai-sdk/workflow`
- Add `initialStartIndex` option for resuming streams from the tail
(negative values like `-50` fetch only the last 50 chunks, useful for
page refresh recovery without replaying the full conversation)
- Implement `x-workflow-stream-tail-index` header resolution to compute
absolute chunk positions from negative start indices, with graceful
fallback to replay-from-start when the header is missing
- Fix positive `startIndex` reconnection: set `chunkIndex` to match the
explicit start position so retries after disconnection resume correctly
- Add `startIndex` per-call override on `ReconnectToStreamOptions`
- Extract `getErrorMessage` utility for proper error formatting in
reconnection failures (avoids `[object Object]` in error messages)
- Update `examples/next-workflow` main page to use
`WorkflowChatTransport` with `useChat`
- Add `examples/next-workflow/test` page with mock API routes that
simulate stream interruption and verify reconnection recovery end-to-end

## Documentation

- **API reference**: New `WorkflowChatTransport` reference page at
`docs/reference/ai-sdk-workflow/workflow-chat-transport` with
constructor parameters, methods, reconnection flow, server requirements,
and examples
- **Workflow agent guide**: New "Resumable Streaming" section with
client and server endpoint examples
- **Transport guide**: New "Workflow Transport" section linking to the
reference
- **Workflow reference index**: Added `WorkflowChatTransport` card

## Manual Verification

1. Started `examples/next-workflow` dev server (`pnpm next dev`)
2. **Happy path** (`/`): Sent "What is the weather in San Francisco?" —
WorkflowAgent called `getWeather` tool, responded with "86°F and windy".
Sent "What is 42 * 17?" — called `calculate` tool, responded "714". Both
messages used `WorkflowChatTransport`.
3. **Stream interruption + reconnection** (`/test`): The test page uses
mock API routes where the POST endpoint sends only 2 of 6 SSE chunks (no
`finish` event), simulating a function timeout. The transport detected
the missing `finish` chunk, automatically reconnected via GET to
`/api/test-chat/{runId}/stream?startIndex=2`, received the remaining
chunks, and displayed the complete message. The transport log panel
confirmed the full lifecycle:
   - `POST response received` (`onChatSendMessage` callback fired)
   - `Status: streaming` (partial stream consumed)
   - Auto-reconnect via GET (transparent to the user)
   - `Chat ended: total chunks=6` (`onChatEnd` callback fired)
   - `Status: ready`

## Checklist

- [x] Tests have been added / updated (for bug fixes / features)
- [x] Documentation has been added / updated (for bug fixes / features)
- [x] A _patch_ changeset for relevant packages has been added (for bug
fixes / features - run `pnpm changeset` in the project root)
- [x] I have reviewed this pull request (self-review)

## Related Issues

Follow-up to #12165

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai/workflow related to WorkflowAgent or Vercel Workflow DevKit in general (useworkflow.dev) feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants