feat(ai): improve AI SDK v6 compatibility with DurableAgent#928
feat(ai): improve AI SDK v6 compatibility with DurableAgent#928KaiKloepfer wants to merge 6 commits intovercel:mainfrom
Conversation
Add LanguageModelV3 support while maintaining full v5 backward compatibility. Model objects (V2/V3) are auto-converted to string IDs for step boundary serialization. V3 finish reasons, usage, and stream parts are normalized to a shape both SDK versions can consume. Tool results with typed ToolResultOutput (content, error-text, error-json, execution-denied) now pass through without re-wrapping, enabling multimodal tool results (images, files) to reach the model. Also adds `instructions` alias for `system`, `totalUsage` in the `onFinish` callback, and improves type safety throughout (StepResult<any> → StepResult<ToolSet>, centralized normalization, shared filterToolSet). Closes vercel#848 Ref vercel#168
🦋 Changeset detectedLatest commit: d1b0d9b The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@KaiKloepfer is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
I, Kai Kloepfer <kai@biofire.io>, hereby add my Signed-off-by to this commit: add2232 Signed-off-by: Kai Kloepfer <kai@biofire.io>
|
|
||
| **String IDs** are the recommended approach. The string is resolved via the AI SDK gateway inside the workflow step, so it crosses the step boundary cleanly. | ||
|
|
||
| **Model objects** from both AI SDK v5 (`LanguageModelV2`) and AI SDK v6 (`LanguageModelV3`) are accepted. They are automatically converted to string IDs using the object's `provider` and `modelId` fields. Middleware and wrappers applied to the model object are **not preserved** across the step boundary — use `providerOptions` to configure provider-specific settings instead. |
There was a problem hiding this comment.
How would this approach work? you can't serialize the doStream etc. functions inside LanguageModelV3 can't be serialized which is why we do the factory function version that allows us to hack around it
There was a problem hiding this comment.
Addressed — reverted the model object auto-conversion in 2519749.
|
I don't think this approach works for will need AI sdk to include "use step"/"use workflow" and/or implement custom serde probably still discussing this with AI sdk so they can implement a proper solution |
|
Hey @pranaygp, thanks for the review! I think this is worth discussing but wanted to point out that the factory function path on main ( Main already claims V3 compatibility in Totally agree that a proper serde solution from the AI SDK would be the right long-term fix though. Happy to hold off on this if you'd rather wait for that. I will note the model string conversion is a pretty small part of this PR, most of it is other fixes like V3 stream part normalization, usage aggregation, and finish reason handling that are currently missing. |
There was a problem hiding this comment.
Good PR — this correctly addresses the real bugs with V6 compatibility that were identified in #929. The centralized normalize.ts approach is clean, the typed tool result passthrough fixes a real gap, and the instructions alias + totalUsage are nice additions.
| } | ||
|
|
||
| const obj = raw as Record<string, unknown>; | ||
|
|
There was a problem hiding this comment.
The V2 branch detection (typeof obj.inputTokens === 'number') is solid, but note that V2 also has totalTokens as a first-class field from the provider. The fallback inputTokens + outputTokens is fine as a safety net, but worth calling out that it may differ slightly from the provider-reported total (e.g., some providers include system tokens in totalTokens but not in inputTokens).
There was a problem hiding this comment.
Good call. The code prefers the provider-reported totalTokens when available and only falls back to inputTokens + outputTokens, so it should pick up system tokens etc. when the provider includes them.
| if (typeof result !== 'object' || result === null) return false; | ||
| const type = (result as Record<string, unknown>).type; | ||
| return typeof type === 'string' && TOOL_RESULT_OUTPUT_TYPES.has(type); | ||
| } |
There was a problem hiding this comment.
This is a runtime duck-type check, which means a tool returning a plain object that happens to have { type: 'text', value: '...' } as its natural shape would be misinterpreted as a typed ToolResultOutput instead of getting wrapped as json. This is unlikely in practice, but worth documenting in the jsdoc that tools should not return objects with a type field matching these values unless they intend it to be a ToolResultOutput.
There was a problem hiding this comment.
Fair point, added a JSDoc noting the caveat. Tools that happen to return { type: 'text', value: '...' } would get misinterpreted, but it's unlikely in practice.
| `a string for step boundary serialization. Middleware and wrappers are not preserved. ` + | ||
| `Use providerOptions to configure provider-specific settings.` | ||
| ); | ||
| return `${model.provider}/${model.modelId}`; |
There was a problem hiding this comment.
The resolveModelId function always logs a console.warn when a model object is passed. Since passing model objects is documented as "Accepted" (not deprecated), this warning may be noisy for users who intentionally use this pattern. Consider making the warning only fire once, or downgrading to a console.info/console.debug, or only warning if middleware is detected (though that's harder to check).
There was a problem hiding this comment.
Moot now — resolveModelId was removed in the revert commit.
| // These interfaces centralize duck-typing assumptions for AI SDK v6 | ||
| // properties that don't exist in the installed @ai-sdk/provider V2 type | ||
| // definitions. When @ai-sdk/provider ships V3 types, replace these with | ||
| // the canonical imports and remove the casts in do-stream-step.ts. |
There was a problem hiding this comment.
Nice improvement — removing doStream from the V3 branch and only keeping the identity properties is the right call. This makes the duck type honest about what's actually used (just provider and modelId for string extraction), rather than pretending V3 accepts V2 call options.
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The tool-approval-request warning is a good addition. However, this will currently just warn and then the tool call will sit there with no response — the model may hang or timeout. Consider whether it's feasible to auto-deny the approval and return an error result so the agent loop can continue, rather than silently stalling.
There was a problem hiding this comment.
Good catch. Changed this to auto-deny with an execution-denied tool result so the agent loop can continue instead of hanging. The handling moved to the first TransformStream so the denied result flows through properly. Still logs a warning.
| const obj = raw as Record<string, unknown>; | ||
| const rawValue = typeof obj.raw === 'string' ? obj.raw : undefined; | ||
| // V3: { unified: 'stop', raw: 'stop' } | ||
| if (typeof obj.unified === 'string') { |
There was a problem hiding this comment.
Edge case: if raw is an empty string "", this currently maps to { finishReason: 'unknown', rawFinishReason: undefined } (because of the raw === '' ? 'unknown' : raw check). But then rawFinishReason is set to raw || undefined which also converts empty string to undefined. This is probably fine since no real provider sends an empty string finish reason, but the divergence between the finishReason path (explicit empty check) and rawFinishReason path (falsy check) is a bit inconsistent.
There was a problem hiding this comment.
Yeah the two paths are slightly inconsistent (=== '' vs falsy check) but converge to the same result for any real input. Could tighten it but didn't want to over-engineer for a case no provider hits.
| result: chunk.result, | ||
| isError: chunk.isError, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The preliminary result handling logic stores the preliminary result only if no result exists yet, and overwrites with the final one. This means if multiple preliminary results arrive for the same tool call, only the first one is kept. Is that intentional? The AI SDK's own handling of preliminary results streams them all through. Might be fine for DurableAgent's use case (we only need the final result), just flagging.
There was a problem hiding this comment.
Intentional — I only need the final result, and the preliminary is just a placeholder until it arrives. If multiple preliminaries come in, the first one is fine since it gets overwritten by the final anyway.
| if (o.type === 'execution-denied') { | ||
| return o.reason ?? 'Tool execution was denied'; | ||
| } | ||
| return output; |
There was a problem hiding this comment.
getToolOutputForUI is clean. One thought: the execution-denied branch falls through to a runtime check rather than being in the type-safe switch — when @ai-sdk/provider adds V3 output types, this should get a proper case arm. The comment already flags this, so just a +1 on the TODO.
I don't think this is true. @TooTallNate can you confirm? The function arg does get serialized across step boundaries, but it's a bit of a hack because we're actually relying on bundling behaviour. i.e. the functions in If you follow the guide here and use "Custom provider" and attempt to use one of the custom providers that we export through the we certainly don't want to break that functionality I think the rest of the PR is something we should get in but I'm very hesitant on magically converting custom providers into a gateway string ✅ Review feedback validated by ClaudeConfirmed by reviewing the serialization system and SWC plugin output:
|
The AI SDK's default download function uses globalThis.fetch, which is unavailable in the workflow VM context. Add a step-based download function so file/image URLs in prompts are fetched inside the step boundary where globalThis.fetch works, and results are persisted to the step log for replay on workflow resumption. Signed-off-by: Kai Kloepfer <kai@biofire.io>
Factory functions (() => Promise<LanguageModel>) work across step boundaries due to bundling behavior, so they should not be deprecated. Remove resolveModelId which magically converted model objects to gateway strings, and restore the original model type signature. Signed-off-by: Kai Kloepfer <kai@biofire.io>
Address maintainer review feedback: - Auto-deny V3 tool-approval-request with execution-denied result instead of just warning, preventing the agent loop from hanging - Add JSDoc to isToolResultOutput documenting the duck-type collision risk Signed-off-by: Kai Kloepfer <kai@biofire.io>
|
Thanks @pranaygp, I haven't reviewed the bundling side in depth at all, so can't comment there. The factory function issue is why I started to dig into this in the first place, I've been trying (basically unsuccessfully due to a series of limitations) as a vercel enterprise customer to get a basic production feature set (observability, prompt management, durable retries, tool approvals, mcp calls, etc.) impl done with workflow, ai sdk and deployed to vercel. This was the first major issue I encountered. That being said, I followed your link and it DID work. Commit pushed to revert, address a few of your other topics, and to fix a further issue, see 61c5347. Here is the pattern I was running into as a user that DOES NOT work: This works: this.agent = new DurableAgent({
model: config.gatewayId,
...
})This causes the below errors: const gatewayId = config.gatewayId
this.agent = new DurableAgent({
model: async () => {
console.debug('[workflow-agent] model factory called (host context)')
return gateway(gatewayId)
},
...
})Errors
Errors in local world: What does work is something like: import { gateway } from '@workflow/ai/gateway'
/**
* Get a serializable model factory for DurableAgent.
*
* Uses `@workflow/ai/gateway` which returns `() => Promise<LanguageModelV2>` where
* the inner function has `'use step'` — making it serializable across the workflow
* step boundary.
*/
export function getDurableModel(name: ModelName): () => Promise<import('@ai-sdk/provider').LanguageModelV2> {
const config = MODEL_CONFIGS[name]
return gateway(config.gatewayId)
}this.agent = new DurableAgent({
model: getDurableModel(options.model),
... |
Add early return after enqueuing the auto-denied tool result so the original tool-approval-request chunk is not also pushed to the stream. Signed-off-by: Kai Kloepfer <kai@biofire.io>
|
@pranaygp @KaiKloepfer Are there still changes being made here or is it ready for re-review? |
Summary
Add AI SDK v6 (LanguageModelV3) compatibility to
DurableAgentwhile maintaining full backward compatibility with AI SDK v5.{unified, raw}objects), usage (nested{total, noCache, cacheRead, ...}), and new stream parts (preliminarytool results,tool-approval-request) are normalized to a shape compatible with both SDK versions.tool-approval-requestauto-deny: V3 tool approval requests are auto-denied with anexecution-deniedresult so the agent loop continues instead of hanging.executeToolnow passes through typedToolResultOutputvalues (content,error-text,error-json,execution-denied) without re-wrapping, enabling multimodal tool results (images, files) to reach the model.globalThis.fetchis available, fixing file/image URL fetching in workflow VM context and persisting results to the step log for replay.instructionsalias: Addedinstructionsas an alias forsystemon both the constructor andstream()options, matching the AI SDK v6ToolLoopAgentAPI.totalUsageinonFinish: Aggregated token usage across all steps is now available in theonFinishcallback.StepResult<any>withStepResult<ToolSet>, correctly categorize static vs dynamic tool calls, extractfilterToolSetto a shared module, centralize normalization logic innormalize.ts.Closes #848
Partially addresses #168 (
instructionsalias)Changes
durable-agent.tsinstructionsalias,totalUsageinonFinish, typed tool result pass-through, type-safe castsnormalize.ts(new)normalizeFinishReason,normalizeUsage,addUsage,NormalizedUsagetypenormalize.test.ts(new)do-stream-step.tsStreamableModelinterface, V3 preliminary tool results,tool-input-startextensions,tool-approval-requestauto-deny, request metadata capturestream-text-iterator.tsgetToolOutputForUIfor V3 output types, use centralized normalizationtypes.tsdoStreamfrom V3 branch, add V3 extension interfacesfilter-tools.ts(new)filterToolSetdurable-agent.test.tsinstructions, typed tool results,totalUsage; use string model IDsstream-text-iterator.test.tsgetToolOutputForUIdo-stream-step.test.ts(deleted)normalize.test.tsdurable-agent.mdx@pranaygp, it looks like you might be the right maintainer for this?