@ai-sdk/workflow 2.0.69 → 2.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,6 +58,7 @@ interface WorkflowChatTransportOptions<UI_MESSAGE extends UIMessage> {
58
58
  onChatEnd?: OnChatEnd;
59
59
  /**
60
60
  * Maximum number of consecutive errors allowed during reconnection attempts.
61
+ * Streams that end without receiving any UI message chunks count as errors.
61
62
  * Defaults to 3 if not provided.
62
63
  */
63
64
  maxConsecutiveErrors?: number;
@@ -158,4 +159,4 @@ declare class WorkflowChatTransport<UI_MESSAGE extends UIMessage> implements Cha
158
159
  }
159
160
  //#endregion
160
161
  export { WorkflowChatTransportOptions as i, SendMessagesOptions as n, WorkflowChatTransport as r, ReconnectToStreamOptions as t };
161
- //# sourceMappingURL=workflow-chat-transport-DwOgX7dK.d.ts.map
162
+ //# sourceMappingURL=workflow-chat-transport-Bw-K44A5.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-chat-transport-Bw-K44A5.d.ts","names":[],"sources":["../src/workflow-chat-transport.ts"],"mappings":";;UAsGiB,oBAAoB,mBAAmB;EACtD;EACA;EACA;EACA,UAAU;EACV,cAAc;;UAGC;EACf;EACA,cAAc;;;;;;;EAOd;;KAGG,kBAAkB,mBAAmB,cACxC,UAAU,UACV,SAAS,oBAAoB,uBACnB;KAEP,eACH,QACA;EAEA;EACA;aACW;;;;;;;UAQI,6BAA6B,mBAAmB;;;;;EAK/D;;;;;EAMA,eAAe;;;;;;;;EASf,oBAAoB,kBAAkB;;;;;;;;EAStC,YAAY;;;;;;EAOZ;;;;;;;;;;;;;EAcA;;;;;EAMA,6BAA6B,2BAA2B;;;;;EAMxD,kCAAkC;;;;;;;;;;;;;;;cAgBvB,sBACX,mBAAmB,sBACR,cAAc;mBACR;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;;;;;;;;;;;;EAcL,YAAA,UAAS,6BAA6B;;;;;;;;;;;;;;;;;;;EA8B5C,aACJ,SAAS,oBAAoB,cAAc,qBAC1C,QAAQ,eAAe;UAMX;;;;;;;;;;;;;EAmGT,kBACJ,SAAS,2BAA2B,qBACnC,QAAQ,eAAe;UAOX;UAiJD"}
@@ -329,6 +329,7 @@ var WorkflowChatTransport = class {
329
329
  }
330
330
  useExplicitStartIndex = false;
331
331
  try {
332
+ const streamStartIndex = chunkIndex;
332
333
  const chunkStream = parseJsonEventStream({
333
334
  stream: response.body,
334
335
  schema: uiMessageChunkSchema
@@ -340,6 +341,7 @@ var WorkflowChatTransport = class {
340
341
  yield chunk.value;
341
342
  if (chunk.value.type === "finish") gotFinish = true;
342
343
  }
344
+ if (chunkIndex === streamStartIndex) throw new Error("Reconnect stream ended without receiving chunks.");
343
345
  consecutiveErrors = 0;
344
346
  } catch (error) {
345
347
  console.error("Error in chat GET reconnectToStream", error);
@@ -363,4 +365,4 @@ var WorkflowChatTransport = class {
363
365
  //#endregion
364
366
  export { normalizeUIMessageStreamParts as n, WorkflowChatTransport as t };
365
367
 
366
- //# sourceMappingURL=workflow-chat-transport-Bduf4ZDx.js.map
368
+ //# sourceMappingURL=workflow-chat-transport-Dj1poviu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-chat-transport-Dj1poviu.js","names":[],"sources":["../src/normalize-ui-message-stream.ts","../src/workflow-chat-transport.ts"],"sourcesContent":["import type { UIMessageChunk } from 'ai';\n\n/**\n * Tracks, for one part family (text or reasoning), which part ids are open or\n * have ended since the latest step boundary.\n */\ninterface PartFrameState {\n /** A `*-start` was seen and has not yet received its explicit `*-end`. */\n open: Set<string>;\n /** A part that ended since the latest step boundary. */\n ended: Set<string>;\n}\n\nconst newPartFrameState = (): PartFrameState => ({\n open: new Set(),\n ended: new Set(),\n});\n\n/**\n * Repairs the framing for a single `*-start` / `*-delta` / `*-end` chunk\n * against the running framing state, yielding the chunks the consumer should\n * see. Text and reasoning parts share this logic (`startType` differentiates\n * the synthesized start chunk).\n *\n * @yields the chunks (possibly synthesized, possibly none) the consumer should see.\n */\nfunction* repairPart(\n kind: 'start' | 'delta' | 'end',\n id: string,\n chunk: UIMessageChunk,\n state: PartFrameState,\n startType: 'text-start' | 'reasoning-start',\n): Generator<UIMessageChunk> {\n if (kind === 'start') {\n // Drop a duplicate/replayed start for a part that is still open or has\n // already ended since the latest step boundary.\n if (state.open.has(id) || state.ended.has(id)) {\n return;\n }\n state.open.add(id);\n yield chunk;\n return;\n }\n\n // delta / end: drop a re-delivered chunk for an already-ended part.\n if (state.ended.has(id)) {\n return;\n }\n // Synthesize the missing start for an orphaned delta/end.\n if (!state.open.has(id)) {\n state.open.add(id);\n yield { type: startType, id } as UIMessageChunk;\n }\n if (kind === 'end') {\n state.open.delete(id);\n state.ended.add(id);\n }\n yield chunk;\n}\n\n/**\n * Normalizes the part framing of a UI message stream so it is always\n * well-formed for the AI SDK's stream consumer (`processUIMessageStream`,\n * which backs `useChat`/`readUIMessageStream`).\n *\n * ## Why this exists\n *\n * The consumer maintains a map of \"active\" text/reasoning parts keyed by id.\n * A `text-delta`/`text-end` for an id that has no open part is a fatal error\n * (`Received text-delta for missing text part with ID \"0\" ...`) that kills the\n * whole turn. Two properties of the durable streaming model make that error\n * reachable:\n *\n * - A workflow run owns a single shared stream. Multi-step turns can reuse the\n * same part id (commonly `\"0\"`), so a dropped or duplicated `*-start` can\n * orphan the rest of that part's content.\n * - The same stream is read across reconnects, and a stream-producing step can\n * run more than once (retry/redelivery, or the concurrent-worker duplication\n * tracked in vercel/workflow#2331 and #2039). Either can interleave or\n * duplicate chunks on the shared stream — e.g. a `finish-step` landing in the\n * middle of another execution's text part.\n *\n * Since the content is still flowing and only the framing is damaged, repairing\n * the framing here degrades the worst case to \"text begins slightly into the\n * step\" or \"a duplicated tail is dropped\" instead of a dead turn.\n *\n * ## What it does\n *\n * Mirrors the consumer's explicit-end part-lifetime state machine per part type:\n * - keeps open parts active across `finish-step`, while allowing ended ids to\n * be reused by a later step;\n * - synthesizes a missing `*-start` when an orphaned `*-delta`/`*-end` arrives;\n * - drops a re-delivered `*-start`/`*-delta`/`*-end` for a part already\n * open or ended since the latest step boundary (reconnect/replay overlap).\n *\n * A well-formed stream passes through unchanged.\n *\n * ## Scope: text and reasoning only\n *\n * `tool-input-delta` raises the same class of fatal error (`Received\n * tool-input-delta for missing tool call ...`), but tool parts are deliberately\n * left untouched: the consumer does not reset its tool-call map on `finish-step`\n * and tool-call ids are unique, so the step-boundary id-reuse orphaning that\n * makes text/reasoning fragile does not apply to them. If a future duplication\n * mode is found to orphan tool-input parts, extend the same machine to that\n * family rather than special-casing it.\n *\n * @param source the raw UI message chunk stream to normalize.\n * @yields the framing-corrected UI message chunks.\n */\nexport async function* normalizeUIMessageStreamParts(\n source: AsyncIterable<UIMessageChunk>,\n): AsyncGenerator<UIMessageChunk> {\n const text = newPartFrameState();\n const reasoning = newPartFrameState();\n\n for await (const chunk of source) {\n switch (chunk.type) {\n case 'reset-step':\n // A retried model-call step starts a new frame. Forget parts from the\n // invalidated attempt so reused ids are framed normally.\n text.open.clear();\n text.ended.clear();\n reasoning.open.clear();\n reasoning.ended.clear();\n yield chunk;\n break;\n\n case 'finish-step':\n // Open parts are closed only by explicit end chunks. A finish-step can\n // come from another interleaved execution while a part is still open.\n // Ended ids may be reused by the next step.\n text.ended.clear();\n reasoning.ended.clear();\n yield chunk;\n break;\n\n case 'text-start':\n yield* repairPart('start', chunk.id, chunk, text, 'text-start');\n break;\n case 'text-delta':\n yield* repairPart('delta', chunk.id, chunk, text, 'text-start');\n break;\n case 'text-end':\n yield* repairPart('end', chunk.id, chunk, text, 'text-start');\n break;\n\n case 'reasoning-start':\n yield* repairPart(\n 'start',\n chunk.id,\n chunk,\n reasoning,\n 'reasoning-start',\n );\n break;\n case 'reasoning-delta':\n yield* repairPart(\n 'delta',\n chunk.id,\n chunk,\n reasoning,\n 'reasoning-start',\n );\n break;\n case 'reasoning-end':\n yield* repairPart('end', chunk.id, chunk, reasoning, 'reasoning-start');\n break;\n\n default:\n yield chunk;\n }\n }\n}\n","import {\n parseJsonEventStream,\n uiMessageChunkSchema,\n type ChatRequestOptions,\n type ChatTransport,\n type PrepareReconnectToStreamRequest,\n type PrepareSendMessagesRequest,\n type UIMessage,\n type UIMessageChunk,\n} from 'ai';\nimport {\n convertAsyncIteratorToReadableStream,\n getErrorMessage,\n} from '@ai-sdk/provider-utils';\nimport { createAsyncIterableStream } from 'ai/internal';\nimport { normalizeUIMessageStreamParts } from './normalize-ui-message-stream.js';\n\n/**\n * Tracks `*-start` chunks the client has accepted so we can drop deltas/ends\n * that refer to a part whose start was emitted before the resume cursor.\n *\n * AI SDK's UI stream processor throws on `text-delta`/`reasoning-delta`/\n * `tool-input-delta` (and the matching `*-end`) when the start chunk for that\n * id was never observed, and on tool output/approval chunks when no tool part\n * exists for the call id. A negative `startIndex` on a flat chunk stream can\n * easily land mid-part, so without this guard the client crashes on resume.\n *\n * A tool part is established by `tool-input-start` OR by a self-contained\n * `tool-input-available`/`tool-input-error` chunk (the AI SDK creates the\n * part from those directly), so all three mark the call id as seen.\n *\n * This is a best-effort safety net — it preserves only the parts that the\n * resumed window includes a `*-start` for. Server-side rewinding to a step\n * boundary is the proper fix when you want the full message preserved.\n */\ntype OrphanFilter = {\n shouldDrop: (chunk: UIMessageChunk) => boolean;\n};\n\nfunction createOrphanFilter(): OrphanFilter {\n const seenStartedIds = new Set<string>();\n const seenStartedToolCallIds = new Set<string>();\n let warnedOnce = false;\n\n function warnOnce(orphanKind: string, orphanRef: string) {\n if (warnedOnce) return;\n warnedOnce = true;\n console.warn(\n '[WorkflowChatTransport] Dropping orphan UI chunk ' +\n `(${orphanKind} for id \"${orphanRef}\") on resume — ` +\n 'the resume position landed mid-part. The dropped chunk(s) ' +\n \"reference a part whose start chunk wasn't in the resumed \" +\n 'window. To preserve the full message, configure your ' +\n 'stream endpoint to rewind to a step boundary before ' +\n 'returning the readable. See: ' +\n 'https://workflow.dev/docs/ai/resumable-streams#mid-part-resumes',\n );\n }\n\n function shouldDrop(chunk: UIMessageChunk): boolean {\n switch (chunk.type) {\n case 'reset-step':\n seenStartedIds.clear();\n seenStartedToolCallIds.clear();\n return false;\n case 'text-start':\n case 'reasoning-start':\n seenStartedIds.add(chunk.id);\n return false;\n case 'tool-input-start':\n // `tool-input-available` / `tool-input-error` are self-contained: the\n // AI SDK creates the tool part from them directly (non-streamed tool\n // calls are emitted as a bare `tool-input-available`), so they must\n // never be dropped. They also carry the full input, so they recover a\n // tool call whose `tool-input-start` fell outside the resumed window.\n case 'tool-input-available':\n case 'tool-input-error':\n seenStartedToolCallIds.add(chunk.toolCallId);\n return false;\n case 'text-delta':\n case 'text-end':\n case 'reasoning-delta':\n case 'reasoning-end':\n if (seenStartedIds.has(chunk.id)) return false;\n warnOnce(chunk.type, chunk.id);\n return true;\n case 'tool-input-delta':\n case 'tool-approval-request':\n case 'tool-output-available':\n case 'tool-output-error':\n case 'tool-output-denied':\n if (seenStartedToolCallIds.has(chunk.toolCallId)) return false;\n warnOnce(chunk.type, chunk.toolCallId);\n return true;\n default:\n return false;\n }\n }\n\n return { shouldDrop };\n}\n\nexport interface SendMessagesOptions<UI_MESSAGE extends UIMessage> {\n trigger: 'submit-message' | 'regenerate-message';\n chatId: string;\n messageId?: string;\n messages: UI_MESSAGE[];\n abortSignal?: AbortSignal;\n}\n\nexport interface ReconnectToStreamOptions {\n chatId: string;\n abortSignal?: AbortSignal;\n /**\n * Override the `startIndex` for this reconnection.\n * Negative values read from the end when the server's durable stream and\n * tail-index header use the same UIMessageChunk index space.\n * When omitted, falls back to the constructor's `initialStartIndex`.\n */\n startIndex?: number;\n}\n\ntype OnChatSendMessage<UI_MESSAGE extends UIMessage> = (\n response: Response,\n options: SendMessagesOptions<UI_MESSAGE>,\n) => void | Promise<void>;\n\ntype OnChatEnd = ({\n chatId,\n chunkIndex,\n}: {\n chatId: string;\n chunkIndex: number;\n}) => void | Promise<void>;\n\n/**\n * Configuration options for the WorkflowChatTransport.\n *\n * @template UI_MESSAGE - The type of UI messages being sent and received,\n * must extend the UIMessage interface from the AI SDK.\n */\nexport interface WorkflowChatTransportOptions<UI_MESSAGE extends UIMessage> {\n /**\n * API endpoint for chat requests\n * Defaults to /api/chat if not provided\n */\n api?: string;\n\n /**\n * Custom fetch implementation to use for HTTP requests.\n * Defaults to the global fetch function if not provided.\n */\n fetch?: typeof fetch;\n\n /**\n * Callback invoked after successfully sending messages to the chat endpoint.\n * Useful for tracking chat history and inspecting response headers.\n *\n * @param response - The HTTP response object from the chat endpoint\n * @param options - The original options passed to sendMessages\n */\n onChatSendMessage?: OnChatSendMessage<UI_MESSAGE>;\n\n /**\n * Callback invoked when a chat stream ends (receives a \"finish\" chunk).\n * Useful for cleanup operations or state updates.\n *\n * @param chatId - The ID of the chat that ended\n * @param chunkIndex - The total number of chunks received\n */\n onChatEnd?: OnChatEnd;\n\n /**\n * Maximum number of consecutive errors allowed during reconnection attempts.\n * Streams that end without receiving any UI message chunks count as errors.\n * Defaults to 3 if not provided.\n */\n maxConsecutiveErrors?: number;\n\n /**\n * Default `startIndex` to use when reconnecting to a stream without a known\n * chunk position (i.e. the initial reconnection, not a retry).\n * Negative values read from the end of a durable UIMessageChunk stream (e.g.\n * `-10` fetches the last 10 chunks), which is useful for resuming a chat UI\n * after a page refresh without replaying the full conversation. Raw\n * ModelCallStreamPart streams do not support negative UI chunk indexes.\n *\n * Can be overridden per-call via `ReconnectToStreamOptions.startIndex`.\n *\n * Defaults to `0` (replay from the beginning).\n */\n initialStartIndex?: number;\n\n /**\n * Function to prepare the request for sending messages.\n * Allows customizing the API endpoint, headers, credentials, and body.\n */\n prepareSendMessagesRequest?: PrepareSendMessagesRequest<UI_MESSAGE>;\n\n /**\n * Function to prepare the request for reconnecting to a stream.\n * Allows customizing the API endpoint, headers, and credentials.\n */\n prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest;\n}\n\n/**\n * A transport implementation for managing chat workflows with support for\n * streaming responses and automatic reconnection to interrupted streams.\n *\n * This class implements the ChatTransport interface from the AI SDK and provides\n * reliable message streaming with automatic recovery from network interruptions\n * or function timeouts.\n *\n * @template UI_MESSAGE - The type of UI messages being sent and received,\n * must extend the UIMessage interface from the AI SDK.\n *\n * @implements {ChatTransport<UI_MESSAGE>}\n */\nexport class WorkflowChatTransport<\n UI_MESSAGE extends UIMessage,\n> implements ChatTransport<UI_MESSAGE> {\n private readonly api: string;\n private readonly fetch: typeof fetch;\n private readonly onChatSendMessage?: OnChatSendMessage<UI_MESSAGE>;\n private readonly onChatEnd?: OnChatEnd;\n private readonly maxConsecutiveErrors: number;\n private readonly initialStartIndex: number;\n private readonly prepareSendMessagesRequest?: PrepareSendMessagesRequest<UI_MESSAGE>;\n private readonly prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest;\n\n /**\n * Creates a new WorkflowChatTransport instance.\n *\n * @param options - Configuration options for the transport\n * @param options.api - API endpoint for chat requests (defaults to '/api/chat')\n * @param options.fetch - Custom fetch implementation (defaults to global fetch)\n * @param options.onChatSendMessage - Callback after sending messages\n * @param options.onChatEnd - Callback when chat stream ends\n * @param options.maxConsecutiveErrors - Maximum consecutive errors for reconnection\n * @param options.prepareSendMessagesRequest - Function to prepare send messages request\n * @param options.prepareReconnectToStreamRequest - Function to prepare reconnect request\n */\n constructor(options: WorkflowChatTransportOptions<UI_MESSAGE> = {}) {\n this.api = options.api ?? '/api/chat';\n this.fetch = options.fetch ?? fetch.bind(globalThis);\n this.onChatSendMessage = options.onChatSendMessage;\n this.onChatEnd = options.onChatEnd;\n this.maxConsecutiveErrors = options.maxConsecutiveErrors ?? 3;\n this.initialStartIndex = options.initialStartIndex ?? 0;\n this.prepareSendMessagesRequest = options.prepareSendMessagesRequest;\n this.prepareReconnectToStreamRequest =\n options.prepareReconnectToStreamRequest;\n }\n\n /**\n * Sends messages to the chat endpoint and returns a stream of response chunks.\n *\n * This method handles the entire chat lifecycle including:\n * - Sending messages to the /api/chat endpoint\n * - Streaming response chunks\n * - Automatic reconnection if the stream is interrupted\n *\n * @param options - Options for sending messages\n * @param options.trigger - The type of message submission ('submit-message' or 'regenerate-message')\n * @param options.chatId - Unique identifier for this chat session\n * @param options.messageId - Optional message ID for tracking specific messages\n * @param options.messages - Array of UI messages to send\n * @param options.abortSignal - Optional AbortSignal to cancel the request\n *\n * @returns A ReadableStream of UIMessageChunk objects containing the response\n * @throws Error if the fetch request fails or returns a non-OK status\n */\n async sendMessages(\n options: SendMessagesOptions<UI_MESSAGE> & ChatRequestOptions,\n ): Promise<ReadableStream<UIMessageChunk>> {\n return convertAsyncIteratorToReadableStream(\n normalizeUIMessageStreamParts(this.sendMessagesIterator(options)),\n );\n }\n\n private async *sendMessagesIterator(\n options: SendMessagesOptions<UI_MESSAGE> & ChatRequestOptions,\n ): AsyncGenerator<UIMessageChunk> {\n const { chatId, messages, abortSignal, trigger, messageId } = options;\n\n // We keep track of if the \"finish\" chunk is received to determine\n // if we need to reconnect, and keep track of the chunk index to resume from.\n let gotFinish = false;\n let chunkIndex = 0;\n\n // Prepare the request using the configurator if provided\n const requestConfig = this.prepareSendMessagesRequest\n ? await this.prepareSendMessagesRequest({\n id: chatId,\n messages,\n requestMetadata: options.metadata,\n body: options.body,\n credentials: undefined,\n headers: options.headers,\n api: this.api,\n trigger,\n messageId,\n })\n : undefined;\n\n const url = requestConfig?.api ?? this.api;\n const response = await this.fetch(url, {\n method: 'POST',\n body: JSON.stringify(\n requestConfig?.body ?? { messages, ...options.body },\n ),\n headers: requestConfig?.headers,\n credentials: requestConfig?.credentials,\n signal: abortSignal,\n });\n\n if (!response.ok || !response.body) {\n throw new Error(\n `Failed to fetch chat: ${response.status} ${await response.text()}`,\n );\n }\n\n const workflowRunId = response.headers.get('x-workflow-run-id');\n if (!workflowRunId) {\n throw new Error(\n 'Workflow run ID not found in \"x-workflow-run-id\" response header',\n );\n }\n\n // Notify the caller that the chat POST request was sent.\n // This is useful for tracking the chat history on the client\n // side and allows for inspecting response headers.\n await this.onChatSendMessage?.(response, options);\n\n // Flush the initial stream until the end or an error occurs\n try {\n const chunkStream = parseJsonEventStream({\n stream: response.body,\n schema: uiMessageChunkSchema,\n });\n for await (const chunk of createAsyncIterableStream(chunkStream)) {\n if (!chunk.success) {\n throw chunk.error;\n }\n\n chunkIndex++;\n\n yield chunk.value;\n\n if (chunk.value.type === 'finish') {\n gotFinish = true;\n }\n }\n } catch (error) {\n console.error('Error in chat POST stream', error);\n }\n\n if (gotFinish) {\n await this.onFinish(gotFinish, { chatId, chunkIndex });\n } else {\n // If the initial POST request did not include the \"finish\" chunk,\n // we need to reconnect to the stream. This could indicate that a\n // network error occurred or the Vercel Function timed out.\n yield* this.reconnectToStreamIterator(options, workflowRunId, chunkIndex);\n }\n }\n\n /**\n * Reconnects to an existing chat stream that was previously interrupted.\n *\n * This method is useful for resuming a chat session after network issues,\n * page refreshes, or Vercel Function timeouts.\n *\n * @param options - Options for reconnecting to the stream\n * @param options.chatId - The chat ID to reconnect to\n *\n * @returns A ReadableStream of UIMessageChunk objects\n * @throws Error if the reconnection request fails or returns a non-OK status\n */\n async reconnectToStream(\n options: ReconnectToStreamOptions & ChatRequestOptions,\n ): Promise<ReadableStream<UIMessageChunk> | null> {\n const reconnectIterator = normalizeUIMessageStreamParts(\n this.reconnectToStreamIterator(options),\n );\n return convertAsyncIteratorToReadableStream(reconnectIterator);\n }\n\n private async *reconnectToStreamIterator(\n options: ReconnectToStreamOptions & ChatRequestOptions,\n workflowRunId?: string,\n initialChunkIndex = 0,\n ): AsyncGenerator<UIMessageChunk> {\n let chunkIndex = initialChunkIndex;\n\n // When called from the public reconnectToStream (initialChunkIndex === 0),\n // honour the caller's startIndex (or the constructor default) for the\n // first request. This enables negative values so the client can read only\n // the tail of the stream (e.g. the last 10 chunks) instead of replaying\n // everything. After the first request, fall back to the running chunkIndex\n // so that retries resume from the correct position.\n const explicitStartIndex = options.startIndex ?? this.initialStartIndex;\n let useExplicitStartIndex =\n initialChunkIndex === 0 && explicitStartIndex !== 0;\n\n const defaultApi = `${this.api}/${encodeURIComponent(workflowRunId ?? options.chatId)}/stream`;\n\n // Prepare the request using the configurator if provided\n const requestConfig = this.prepareReconnectToStreamRequest\n ? await this.prepareReconnectToStreamRequest({\n id: options.chatId,\n requestMetadata: options.metadata,\n body: undefined,\n credentials: undefined,\n headers: undefined,\n api: defaultApi,\n })\n : undefined;\n\n const baseUrl = requestConfig?.api ?? defaultApi;\n\n let gotFinish = false;\n let consecutiveErrors = 0;\n // When a negative startIndex is used but the tail-index header is absent,\n // retries fall back to startIndex 0 (replay everything) instead of using\n // the incremental chunkIndex which would be wrong.\n let replayFromStart = false;\n\n // When resuming with a negative startIndex, the resolved chunk can land in\n // the middle of a `*-start` / `*-delta` / `*-end` sequence, which crashes\n // the AI SDK UI stream processor. The orphan filter drops chunks whose\n // start chunk was emitted before the resume window. Only activated for\n // negative resumes — non-negative startIndex is the caller's explicit\n // choice and we trust them. See: https://github.com/vercel/workflow/issues/1835\n const orphanFilter =\n useExplicitStartIndex && explicitStartIndex < 0\n ? createOrphanFilter()\n : null;\n\n while (!gotFinish) {\n const startIndex = useExplicitStartIndex\n ? explicitStartIndex\n : replayFromStart\n ? 0\n : chunkIndex;\n\n const url = `${baseUrl}?startIndex=${startIndex}`;\n const response = await this.fetch(url, {\n headers: requestConfig?.headers,\n credentials: requestConfig?.credentials,\n signal: options.abortSignal,\n });\n\n if (!response.ok || !response.body) {\n throw new Error(\n `Failed to fetch chat: ${response.status} ${await response.text()}`,\n );\n }\n\n // When using a negative startIndex, the server resolves it to an\n // absolute position. The reconnection endpoint should return the tail\n // index so we can compute the resolved position for subsequent retries.\n if (useExplicitStartIndex && explicitStartIndex > 0) {\n // Positive startIndex: the first request starts at this absolute\n // position, so set chunkIndex to match so subsequent retries\n // resume from (explicitStartIndex + chunks received).\n chunkIndex = explicitStartIndex;\n } else if (useExplicitStartIndex && explicitStartIndex < 0) {\n const tailIndexHeader = response.headers.get(\n 'x-workflow-stream-tail-index',\n );\n const tailIndex =\n tailIndexHeader !== null ? parseInt(tailIndexHeader, 10) : NaN;\n\n if (!Number.isNaN(tailIndex)) {\n // Resolve: e.g. tailIndex=499, startIndex=-20 → 500 + (-20) = 480\n chunkIndex = Math.max(0, tailIndex + 1 + explicitStartIndex);\n } else {\n // Header missing or unparseable — fall back to replaying from the\n // beginning so retries don't resume from a wrong position.\n console.warn(\n '[WorkflowChatTransport] Negative initialStartIndex is configured ' +\n `(${explicitStartIndex}) but the reconnection endpoint did not ` +\n 'return a valid \"x-workflow-stream-tail-index\" header. Retries ' +\n 'will replay the stream from the beginning. See: ' +\n 'https://workflow.dev/docs/ai/resumable-streams#resuming-from-the-end-of-the-stream',\n );\n replayFromStart = true;\n }\n }\n useExplicitStartIndex = false;\n\n try {\n const streamStartIndex = chunkIndex;\n const chunkStream = parseJsonEventStream({\n stream: response.body,\n schema: uiMessageChunkSchema,\n });\n for await (const chunk of createAsyncIterableStream(chunkStream)) {\n if (!chunk.success) {\n throw chunk.error;\n }\n\n chunkIndex++;\n\n if (orphanFilter?.shouldDrop(chunk.value)) continue;\n\n yield chunk.value;\n\n if (chunk.value.type === 'finish') {\n gotFinish = true;\n }\n }\n if (chunkIndex === streamStartIndex) {\n throw new Error('Reconnect stream ended without receiving chunks.');\n }\n // Reset consecutive errors only after successful parsing with progress.\n consecutiveErrors = 0;\n } catch (error) {\n console.error('Error in chat GET reconnectToStream', error);\n consecutiveErrors++;\n\n if (consecutiveErrors >= this.maxConsecutiveErrors) {\n throw new Error(\n `Failed to reconnect after ${this.maxConsecutiveErrors} consecutive errors. Last error: ${getErrorMessage(error)}`,\n );\n }\n }\n }\n\n await this.onFinish(gotFinish, { chatId: options.chatId, chunkIndex });\n }\n\n private async onFinish(\n gotFinish: boolean,\n { chatId, chunkIndex }: { chatId: string; chunkIndex: number },\n ) {\n if (gotFinish) {\n await this.onChatEnd?.({ chatId, chunkIndex });\n } else {\n throw new Error('No finish chunk received');\n }\n }\n}\n"],"mappings":";;;;AAaA,MAAM,2BAA2C;CAC/C,sBAAM,IAAI,IAAI;CACd,uBAAO,IAAI,IAAI;AACjB;;;;;;;;;AAUA,UAAU,WACR,MACA,IACA,OACA,OACA,WAC2B;CAC3B,IAAI,SAAS,SAAS;EAGpB,IAAI,MAAM,KAAK,IAAI,EAAE,KAAK,MAAM,MAAM,IAAI,EAAE,GAC1C;EAEF,MAAM,KAAK,IAAI,EAAE;EACjB,MAAM;EACN;CACF;CAGA,IAAI,MAAM,MAAM,IAAI,EAAE,GACpB;CAGF,IAAI,CAAC,MAAM,KAAK,IAAI,EAAE,GAAG;EACvB,MAAM,KAAK,IAAI,EAAE;EACjB,MAAM;GAAE,MAAM;GAAW;EAAG;CAC9B;CACA,IAAI,SAAS,OAAO;EAClB,MAAM,KAAK,OAAO,EAAE;EACpB,MAAM,MAAM,IAAI,EAAE;CACpB;CACA,MAAM;AACR;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDA,gBAAuB,8BACrB,QACgC;CAChC,MAAM,OAAO,kBAAkB;CAC/B,MAAM,YAAY,kBAAkB;CAEpC,WAAW,MAAM,SAAS,QACxB,QAAQ,MAAM,MAAd;EACE,KAAK;GAGH,KAAK,KAAK,MAAM;GAChB,KAAK,MAAM,MAAM;GACjB,UAAU,KAAK,MAAM;GACrB,UAAU,MAAM,MAAM;GACtB,MAAM;GACN;EAEF,KAAK;GAIH,KAAK,MAAM,MAAM;GACjB,UAAU,MAAM,MAAM;GACtB,MAAM;GACN;EAEF,KAAK;GACH,OAAO,WAAW,SAAS,MAAM,IAAI,OAAO,MAAM,YAAY;GAC9D;EACF,KAAK;GACH,OAAO,WAAW,SAAS,MAAM,IAAI,OAAO,MAAM,YAAY;GAC9D;EACF,KAAK;GACH,OAAO,WAAW,OAAO,MAAM,IAAI,OAAO,MAAM,YAAY;GAC5D;EAEF,KAAK;GACH,OAAO,WACL,SACA,MAAM,IACN,OACA,WACA,iBACF;GACA;EACF,KAAK;GACH,OAAO,WACL,SACA,MAAM,IACN,OACA,WACA,iBACF;GACA;EACF,KAAK;GACH,OAAO,WAAW,OAAO,MAAM,IAAI,OAAO,WAAW,iBAAiB;GACtE;EAEF,SACE,MAAM;CACV;AAEJ;;;ACtIA,SAAS,qBAAmC;CAC1C,MAAM,iCAAiB,IAAI,IAAY;CACvC,MAAM,yCAAyB,IAAI,IAAY;CAC/C,IAAI,aAAa;CAEjB,SAAS,SAAS,YAAoB,WAAmB;EACvD,IAAI,YAAY;EAChB,aAAa;EACb,QAAQ,KACN,qDACM,WAAW,WAAW,UAAU,wUAOxC;CACF;CAEA,SAAS,WAAW,OAAgC;EAClD,QAAQ,MAAM,MAAd;GACE,KAAK;IACH,eAAe,MAAM;IACrB,uBAAuB,MAAM;IAC7B,OAAO;GACT,KAAK;GACL,KAAK;IACH,eAAe,IAAI,MAAM,EAAE;IAC3B,OAAO;GACT,KAAK;GAML,KAAK;GACL,KAAK;IACH,uBAAuB,IAAI,MAAM,UAAU;IAC3C,OAAO;GACT,KAAK;GACL,KAAK;GACL,KAAK;GACL,KAAK;IACH,IAAI,eAAe,IAAI,MAAM,EAAE,GAAG,OAAO;IACzC,SAAS,MAAM,MAAM,MAAM,EAAE;IAC7B,OAAO;GACT,KAAK;GACL,KAAK;GACL,KAAK;GACL,KAAK;GACL,KAAK;IACH,IAAI,uBAAuB,IAAI,MAAM,UAAU,GAAG,OAAO;IACzD,SAAS,MAAM,MAAM,MAAM,UAAU;IACrC,OAAO;GACT,SACE,OAAO;EACX;CACF;CAEA,OAAO,EAAE,WAAW;AACtB;;;;;;;;;;;;;;AAuHA,IAAa,wBAAb,MAEuC;;;;;;;;;;;;;CAsBrC,YAAY,UAAoD,CAAC,GAAG;EAClE,KAAK,MAAM,QAAQ,OAAO;EAC1B,KAAK,QAAQ,QAAQ,SAAS,MAAM,KAAK,UAAU;EACnD,KAAK,oBAAoB,QAAQ;EACjC,KAAK,YAAY,QAAQ;EACzB,KAAK,uBAAuB,QAAQ,wBAAwB;EAC5D,KAAK,oBAAoB,QAAQ,qBAAqB;EACtD,KAAK,6BAA6B,QAAQ;EAC1C,KAAK,kCACH,QAAQ;CACZ;;;;;;;;;;;;;;;;;;;CAoBA,MAAM,aACJ,SACyC;EACzC,OAAO,qCACL,8BAA8B,KAAK,qBAAqB,OAAO,CAAC,CAClE;CACF;CAEA,OAAe,qBACb,SACgC;EAChC,MAAM,EAAE,QAAQ,UAAU,aAAa,SAAS,cAAc;EAI9D,IAAI,YAAY;EAChB,IAAI,aAAa;EAGjB,MAAM,gBAAgB,KAAK,6BACvB,MAAM,KAAK,2BAA2B;GACpC,IAAI;GACJ;GACA,iBAAiB,QAAQ;GACzB,MAAM,QAAQ;GACd,aAAa,KAAA;GACb,SAAS,QAAQ;GACjB,KAAK,KAAK;GACV;GACA;EACF,CAAC,IACD,KAAA;EAEJ,MAAM,MAAM,eAAe,OAAO,KAAK;EACvC,MAAM,WAAW,MAAM,KAAK,MAAM,KAAK;GACrC,QAAQ;GACR,MAAM,KAAK,UACT,eAAe,QAAQ;IAAE;IAAU,GAAG,QAAQ;GAAK,CACrD;GACA,SAAS,eAAe;GACxB,aAAa,eAAe;GAC5B,QAAQ;EACV,CAAC;EAED,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,MAC5B,MAAM,IAAI,MACR,yBAAyB,SAAS,OAAO,GAAG,MAAM,SAAS,KAAK,GAClE;EAGF,MAAM,gBAAgB,SAAS,QAAQ,IAAI,mBAAmB;EAC9D,IAAI,CAAC,eACH,MAAM,IAAI,MACR,oEACF;EAMF,MAAM,KAAK,oBAAoB,UAAU,OAAO;EAGhD,IAAI;GACF,MAAM,cAAc,qBAAqB;IACvC,QAAQ,SAAS;IACjB,QAAQ;GACV,CAAC;GACD,WAAW,MAAM,SAAS,0BAA0B,WAAW,GAAG;IAChE,IAAI,CAAC,MAAM,SACT,MAAM,MAAM;IAGd;IAEA,MAAM,MAAM;IAEZ,IAAI,MAAM,MAAM,SAAS,UACvB,YAAY;GAEhB;EACF,SAAS,OAAO;GACd,QAAQ,MAAM,6BAA6B,KAAK;EAClD;EAEA,IAAI,WACF,MAAM,KAAK,SAAS,WAAW;GAAE;GAAQ;EAAW,CAAC;OAKrD,OAAO,KAAK,0BAA0B,SAAS,eAAe,UAAU;CAE5E;;;;;;;;;;;;;CAcA,MAAM,kBACJ,SACgD;EAChD,MAAM,oBAAoB,8BACxB,KAAK,0BAA0B,OAAO,CACxC;EACA,OAAO,qCAAqC,iBAAiB;CAC/D;CAEA,OAAe,0BACb,SACA,eACA,oBAAoB,GACY;EAChC,IAAI,aAAa;EAQjB,MAAM,qBAAqB,QAAQ,cAAc,KAAK;EACtD,IAAI,wBACF,sBAAsB,KAAK,uBAAuB;EAEpD,MAAM,aAAa,GAAG,KAAK,IAAI,GAAG,mBAAmB,iBAAiB,QAAQ,MAAM,EAAE;EAGtF,MAAM,gBAAgB,KAAK,kCACvB,MAAM,KAAK,gCAAgC;GACzC,IAAI,QAAQ;GACZ,iBAAiB,QAAQ;GACzB,MAAM,KAAA;GACN,aAAa,KAAA;GACb,SAAS,KAAA;GACT,KAAK;EACP,CAAC,IACD,KAAA;EAEJ,MAAM,UAAU,eAAe,OAAO;EAEtC,IAAI,YAAY;EAChB,IAAI,oBAAoB;EAIxB,IAAI,kBAAkB;EAQtB,MAAM,eACJ,yBAAyB,qBAAqB,IAC1C,mBAAmB,IACnB;EAEN,OAAO,CAAC,WAAW;GAOjB,MAAM,MAAM,GAAG,QAAQ,cANJ,wBACf,qBACA,kBACE,IACA;GAGN,MAAM,WAAW,MAAM,KAAK,MAAM,KAAK;IACrC,SAAS,eAAe;IACxB,aAAa,eAAe;IAC5B,QAAQ,QAAQ;GAClB,CAAC;GAED,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,MAC5B,MAAM,IAAI,MACR,yBAAyB,SAAS,OAAO,GAAG,MAAM,SAAS,KAAK,GAClE;GAMF,IAAI,yBAAyB,qBAAqB,GAIhD,aAAa;QACR,IAAI,yBAAyB,qBAAqB,GAAG;IAC1D,MAAM,kBAAkB,SAAS,QAAQ,IACvC,8BACF;IACA,MAAM,YACJ,oBAAoB,OAAO,SAAS,iBAAiB,EAAE,IAAI;IAE7D,IAAI,CAAC,OAAO,MAAM,SAAS,GAEzB,aAAa,KAAK,IAAI,GAAG,YAAY,IAAI,kBAAkB;SACtD;KAGL,QAAQ,KACN,qEACM,mBAAmB,yOAI3B;KACA,kBAAkB;IACpB;GACF;GACA,wBAAwB;GAExB,IAAI;IACF,MAAM,mBAAmB;IACzB,MAAM,cAAc,qBAAqB;KACvC,QAAQ,SAAS;KACjB,QAAQ;IACV,CAAC;IACD,WAAW,MAAM,SAAS,0BAA0B,WAAW,GAAG;KAChE,IAAI,CAAC,MAAM,SACT,MAAM,MAAM;KAGd;KAEA,IAAI,cAAc,WAAW,MAAM,KAAK,GAAG;KAE3C,MAAM,MAAM;KAEZ,IAAI,MAAM,MAAM,SAAS,UACvB,YAAY;IAEhB;IACA,IAAI,eAAe,kBACjB,MAAM,IAAI,MAAM,kDAAkD;IAGpE,oBAAoB;GACtB,SAAS,OAAO;IACd,QAAQ,MAAM,uCAAuC,KAAK;IAC1D;IAEA,IAAI,qBAAqB,KAAK,sBAC5B,MAAM,IAAI,MACR,6BAA6B,KAAK,qBAAqB,mCAAmC,gBAAgB,KAAK,GACjH;GAEJ;EACF;EAEA,MAAM,KAAK,SAAS,WAAW;GAAE,QAAQ,QAAQ;GAAQ;EAAW,CAAC;CACvE;CAEA,MAAc,SACZ,WACA,EAAE,QAAQ,cACV;EACA,IAAI,WACF,MAAM,KAAK,YAAY;GAAE;GAAQ;EAAW,CAAC;OAE7C,MAAM,IAAI,MAAM,0BAA0B;CAE9C;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/workflow",
3
- "version": "2.0.69",
3
+ "version": "2.0.71",
4
4
  "type": "module",
5
5
  "description": "WorkflowAgent for building AI agents with AI SDK",
6
6
  "license": "Apache-2.0",
@@ -36,12 +36,15 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@ai-sdk/provider": "4.0.26",
40
- "@ai-sdk/provider-utils": "5.0.58",
41
- "ai": "7.0.137",
39
+ "@ai-sdk/provider": "4.0.27",
40
+ "@ai-sdk/provider-utils": "5.0.59",
41
+ "ai": "7.0.139",
42
42
  "ajv": "^8.20.0"
43
43
  },
44
44
  "devDependencies": {
45
+ "@ai-sdk/otel": "1.0.139",
46
+ "@opentelemetry/api": "1.9.1",
47
+ "@opentelemetry/sdk-trace-base": "2.7.1",
45
48
  "@types/node": "22.19.19",
46
49
  "@vercel/ai-tsconfig": "0.0.0",
47
50
  "@workflow/vitest": "5.0.2",
@@ -49,7 +52,7 @@
49
52
  "typescript": "6.0.3",
50
53
  "vitest": "4.1.11",
51
54
  "workflow": "5.1.0",
52
- "zod": "4.4.3"
55
+ "zod": "4.5.4"
53
56
  },
54
57
  "peerDependencies": {
55
58
  "workflow": "^5.1.0",
@@ -25,6 +25,7 @@ export async function buildModelStepResult(
25
25
  finish: ModelCallFinish | undefined,
26
26
  providerExecutedToolResults: Map<string, ProviderExecutedToolResult>,
27
27
  opts: {
28
+ callId?: string;
28
29
  tools?: ToolSet;
29
30
  requestMessages?: ModelMessage[];
30
31
  stepNumber: number;
@@ -72,7 +73,7 @@ export async function buildModelStepResult(
72
73
  const rate = (tokens: number | undefined) =>
73
74
  calculateTokensPerSecond({ tokens, durationMs: duration });
74
75
  return new DefaultStepResult({
75
- callId: 'workflow-agent',
76
+ callId: opts.callId ?? 'workflow-agent',
76
77
  stepNumber: opts.stepNumber,
77
78
  provider: raw.generation.provider,
78
79
  modelId: raw.generation.modelId,
@@ -258,7 +259,7 @@ export async function buildModelStepResult(
258
259
  ) as StepResult<ToolSet, any>['toolResults'];
259
260
 
260
261
  return {
261
- callId: 'workflow-agent',
262
+ callId: opts.callId ?? 'workflow-agent',
262
263
  stepNumber: opts.stepNumber,
263
264
  model: {
264
265
  provider: responseMetadata?.modelId?.split(':')[0] ?? 'unknown',
@@ -7,6 +7,7 @@ import {
7
7
  } from 'ai';
8
8
  import {
9
9
  asLanguageModelUsage,
10
+ convertToLanguageModelPrompt,
10
11
  parseToolCall,
11
12
  prepareLanguageModelCallOptions,
12
13
  prepareRetries,
@@ -14,6 +15,7 @@ import {
14
15
  prepareTools,
15
16
  resolveLanguageModel,
16
17
  } from 'ai/internal';
18
+ import { languageModelPromptToModelMessages } from './language-model-prompt-to-model-messages.js';
17
19
  import type {
18
20
  ModelCallOptions,
19
21
  ModelCallRawContentPart,
@@ -25,6 +27,10 @@ import {
25
27
  resolveSerializableTools,
26
28
  type SerializableToolDef,
27
29
  } from './serializable-schema.js';
30
+ import {
31
+ runModelCallWithTelemetry,
32
+ type LanguageModelCallTelemetry,
33
+ } from './model-call-telemetry.js';
28
34
 
29
35
  /** A single durable provider call; orchestration and callbacks stay in the workflow. */
30
36
  export async function doGenerateStep(
@@ -35,19 +41,34 @@ export async function doGenerateStep(
35
41
  ): Promise<ModelCallResult> {
36
42
  'use step';
37
43
 
38
- try {
39
- return await generateModelCall(prompt, modelInit, serializedTools, options);
40
- } catch (error) {
41
- // Carry failures as data so Workflow does not normalize arbitrary thrown
42
- // values (including undefined) before the agent rejects in workflow code.
43
- return {
44
- toolCalls: [],
45
- finish: undefined,
46
- raw: { content: [], reasoning: [] },
47
- providerExecutedToolResults: new Map(),
48
- terminalError: error,
49
- };
50
- }
44
+ return runModelCallWithTelemetry({
45
+ telemetry: options.telemetry,
46
+ modelInit,
47
+ prompt,
48
+ serializedTools,
49
+ options,
50
+ execute: async languageModelCallTelemetry => {
51
+ try {
52
+ return await generateModelCall(
53
+ prompt,
54
+ modelInit,
55
+ serializedTools,
56
+ options,
57
+ languageModelCallTelemetry,
58
+ );
59
+ } catch (error) {
60
+ // Carry failures as data so Workflow does not normalize arbitrary thrown
61
+ // values (including undefined) before the agent rejects in workflow code.
62
+ return {
63
+ toolCalls: [],
64
+ finish: undefined,
65
+ raw: { content: [], reasoning: [] },
66
+ providerExecutedToolResults: new Map(),
67
+ terminalError: error,
68
+ };
69
+ }
70
+ },
71
+ });
51
72
  }
52
73
 
53
74
  async function generateModelCall(
@@ -55,6 +76,7 @@ async function generateModelCall(
55
76
  modelInit: LanguageModel,
56
77
  serializedTools: Record<string, SerializableToolDef>,
57
78
  options: ModelCallOptions,
79
+ languageModelCallTelemetry: LanguageModelCallTelemetry | undefined,
58
80
  ): Promise<ModelCallResult> {
59
81
  const remaining =
60
82
  options.timeoutAt == null ? undefined : options.timeoutAt - Date.now();
@@ -70,6 +92,16 @@ async function generateModelCall(
70
92
  AbortSignal.timeout(remaining),
71
93
  ]);
72
94
  const model = resolveLanguageModel(modelInit);
95
+ const modelPrompt = await convertToLanguageModelPrompt({
96
+ prompt: {
97
+ instructions: undefined,
98
+ messages: languageModelPromptToModelMessages(prompt),
99
+ },
100
+ supportedUrls: await model.supportedUrls,
101
+ download: options.download,
102
+ abortSignal,
103
+ provider: model.provider.split('.')[0],
104
+ });
73
105
  const tools = resolveSerializableTools(serializedTools);
74
106
  const toolChoice = prepareToolChoice({ toolChoice: options.toolChoice });
75
107
  const modelTools = await prepareTools({ tools });
@@ -78,6 +110,20 @@ async function generateModelCall(
78
110
  maxRetries: options.maxRetries,
79
111
  abortSignal,
80
112
  });
113
+ const callId = languageModelCallTelemetry?.callId;
114
+ const executeLanguageModelCall =
115
+ languageModelCallTelemetry?.executeLanguageModelCallInTelemetryContext ??
116
+ (({ execute }) => execute());
117
+ await languageModelCallTelemetry?.onLanguageModelCallStart?.({
118
+ callId,
119
+ provider: model.provider,
120
+ modelId: model.modelId,
121
+ messages: prompt,
122
+ tools: modelTools,
123
+ ...settings,
124
+ providerOptions: options.providerOptions,
125
+ headers: options.headers,
126
+ });
81
127
  const start = Date.now();
82
128
  const response = await retry(async () => {
83
129
  abortSignal?.throwIfAborted();
@@ -86,19 +132,36 @@ async function generateModelCall(
86
132
  'The generation deadline expired.',
87
133
  'TimeoutError',
88
134
  );
89
- return model.doGenerate({
90
- ...settings,
91
- prompt: [...prompt],
92
- tools: modelTools,
93
- toolChoice,
94
- responseFormat: options.responseFormat,
95
- providerOptions: options.providerOptions,
96
- headers: options.headers,
97
- abortSignal,
135
+ return executeLanguageModelCall({
136
+ callId: callId ?? '',
137
+ execute: () =>
138
+ model.doGenerate({
139
+ ...settings,
140
+ prompt: modelPrompt,
141
+ tools: modelTools,
142
+ toolChoice,
143
+ responseFormat: options.responseFormat,
144
+ providerOptions: options.providerOptions,
145
+ headers: options.headers,
146
+ abortSignal,
147
+ }),
98
148
  });
99
149
  });
100
- abortSignal?.throwIfAborted();
101
150
  const responseTimeMs = Date.now() - start;
151
+ await languageModelCallTelemetry?.onLanguageModelCallEnd?.({
152
+ callId,
153
+ provider: model.provider,
154
+ modelId: response.response?.modelId ?? model.modelId,
155
+ finishReason: response.finishReason.unified,
156
+ usage: asLanguageModelUsage(response.usage),
157
+ content: response.content,
158
+ responseId: response.response?.id,
159
+ ...(response.providerMetadata != null
160
+ ? { providerMetadata: response.providerMetadata }
161
+ : {}),
162
+ performance: { responseTimeMs },
163
+ });
164
+ abortSignal?.throwIfAborted();
102
165
  const toolCalls = await Promise.all(
103
166
  response.content
104
167
  .filter(part => part.type === 'tool-call')
@@ -108,7 +171,7 @@ async function generateModelCall(
108
171
  tools,
109
172
  repairToolCall: options.repairToolCall,
110
173
  instructions:
111
- prompt
174
+ modelPrompt
112
175
  .filter(message => message.role === 'system')
113
176
  .map(message => message.content)
114
177
  .join('\n') || undefined,
@@ -7,17 +7,22 @@ import { asArray, isAbortError } from '@ai-sdk/provider-utils';
7
7
  import {
8
8
  experimental_streamLanguageModelCall as streamModelCall,
9
9
  gateway,
10
+ NoOutputGeneratedError,
10
11
  wrapLanguageModel,
11
12
  type LanguageModel,
12
- type ModelMessage,
13
13
  type ToolSet,
14
14
  } from 'ai';
15
- import { prepareRetries } from 'ai/internal';
15
+ import { isOutputChunk, prepareRetries } from 'ai/internal';
16
+ import { languageModelPromptToModelMessages } from './language-model-prompt-to-model-messages.js';
16
17
  import type { StreamTextTransform } from './workflow-agent.js';
17
18
  import {
18
19
  resolveSerializableTools,
19
20
  type SerializableToolDef,
20
21
  } from './serializable-schema.js';
22
+ import {
23
+ runModelCallWithTelemetry,
24
+ type LanguageModelCallTelemetry,
25
+ } from './model-call-telemetry.js';
21
26
 
22
27
  import type {
23
28
  ModelCallFinish as StreamFinish,
@@ -54,6 +59,36 @@ export async function doStreamStep(
54
59
  ): Promise<DoStreamStepResult> {
55
60
  'use step';
56
61
 
62
+ return runModelCallWithTelemetry({
63
+ telemetry: options?.telemetry,
64
+ modelInit,
65
+ prompt: conversationPrompt,
66
+ serializedTools,
67
+ options,
68
+ execute: languageModelCallTelemetry =>
69
+ streamModelStep(
70
+ conversationPrompt,
71
+ modelInit,
72
+ writable,
73
+ serializedTools,
74
+ options,
75
+ languageModelCallTelemetry,
76
+ ),
77
+ });
78
+ }
79
+
80
+ // Model-call retries are handled below so the workflow runtime must not add
81
+ // another retry layer around the durable step.
82
+ doStreamStep.maxRetries = 0;
83
+
84
+ async function streamModelStep(
85
+ conversationPrompt: LanguageModelV4Prompt,
86
+ modelInit: LanguageModel,
87
+ writable: WritableStream<ModelCallStreamPart<ToolSet>> | undefined,
88
+ serializedTools: Record<string, SerializableToolDef> | undefined,
89
+ options: DoStreamStepOptions | undefined,
90
+ languageModelCallTelemetry: LanguageModelCallTelemetry | undefined,
91
+ ): Promise<DoStreamStepResult> {
57
92
  const timeout =
58
93
  options?.timeoutAt == null ? undefined : options.timeoutAt - Date.now();
59
94
 
@@ -143,21 +178,7 @@ export async function doStreamStep(
143
178
  // streamModelCall expects Prompt (ModelMessage[]) but we pass the
144
179
  // pre-converted LanguageModelV4Prompt. standardizePrompt inside
145
180
  // streamModelCall handles both formats.
146
- messages: conversationPrompt.map(message =>
147
- message.role !== 'tool'
148
- ? message
149
- : {
150
- ...message,
151
- // Provider prompt approval responses have already been filtered by
152
- // convertToLanguageModelPrompt. Restore the marker expected by the
153
- // model-call helper when it converts these messages again.
154
- content: message.content.map(part =>
155
- part.type === 'tool-approval-response'
156
- ? { ...part, providerExecuted: true }
157
- : part,
158
- ),
159
- },
160
- ) as unknown as ModelMessage[],
181
+ messages: languageModelPromptToModelMessages(conversationPrompt),
161
182
  allowSystemInMessages: true,
162
183
  tools,
163
184
  toolChoice: options?.toolChoice,
@@ -176,6 +197,8 @@ export async function doStreamStep(
176
197
  stopSequences: options?.stopSequences,
177
198
  seed: options?.seed,
178
199
  repairToolCall: options?.repairToolCall,
200
+ download: options?.download,
201
+ ...languageModelCallTelemetry,
179
202
  }),
180
203
  );
181
204
 
@@ -216,6 +239,7 @@ export async function doStreamStep(
216
239
  let performance: ModelCallPerformance | undefined;
217
240
  let terminalError: unknown;
218
241
  let hasTerminalError = false;
242
+ let hasReceivedOutputChunk = false;
219
243
  const ongoingToolCallToolNames = new Map<string, string>();
220
244
 
221
245
  // Acquire writer once before the loop to avoid per-chunk lock overhead
@@ -228,6 +252,10 @@ export async function doStreamStep(
228
252
  await writer?.write({ type: 'reset-step' });
229
253
 
230
254
  for await (const part of modelStream) {
255
+ if (isOutputChunk(part)) {
256
+ hasReceivedOutputChunk = true;
257
+ }
258
+
231
259
  switch (part.type) {
232
260
  case 'tool-input-start':
233
261
  ongoingToolCallToolNames.set(part.id, part.toolName);
@@ -452,6 +480,14 @@ export async function doStreamStep(
452
480
  return { aborted: true };
453
481
  }
454
482
 
483
+ if (finish == null && !hasReceivedOutputChunk && !hasTerminalError) {
484
+ terminalError = new NoOutputGeneratedError({
485
+ message:
486
+ 'No output generated. The model stream ended without a finish chunk.',
487
+ });
488
+ hasTerminalError = true;
489
+ }
490
+
455
491
  return {
456
492
  toolCalls,
457
493
  finish,
@@ -468,10 +504,6 @@ export async function doStreamStep(
468
504
  };
469
505
  }
470
506
 
471
- // Model-call retries are handled above so the workflow runtime must not add
472
- // another retry layer around the durable step.
473
- doStreamStep.maxRetries = 0;
474
-
475
507
  function applyStreamTransforms({
476
508
  stream,
477
509
  transforms,
@@ -0,0 +1,28 @@
1
+ import type { LanguageModelV4Prompt } from '@ai-sdk/provider';
2
+ import type { ModelMessage } from 'ai';
3
+
4
+ /**
5
+ * Prepares a workflow-side `LanguageModelV4Prompt` for a second, model-aware
6
+ * `convertToLanguageModelPrompt` pass inside a model step.
7
+ *
8
+ * The workflow-side conversion preserves URLs (it cannot download them) and
9
+ * filters out approval responses for tools that are not provider-executed.
10
+ * The remaining approval responses are restored with the `providerExecuted`
11
+ * marker that the second conversion expects.
12
+ */
13
+ export function languageModelPromptToModelMessages(
14
+ prompt: LanguageModelV4Prompt,
15
+ ): ModelMessage[] {
16
+ return prompt.map(message =>
17
+ message.role !== 'tool'
18
+ ? message
19
+ : {
20
+ ...message,
21
+ content: message.content.map(part =>
22
+ part.type === 'tool-approval-response'
23
+ ? { ...part, providerExecuted: true }
24
+ : part,
25
+ ),
26
+ },
27
+ ) as unknown as ModelMessage[];
28
+ }
@@ -24,6 +24,7 @@ import {
24
24
  import { buildModelStepResult } from './build-model-step-result.js';
25
25
  import { doGenerateStep } from './do-generate-step.js';
26
26
  import { doStreamStep } from './do-stream-step.js';
27
+ import { shouldDispatchModelCallTelemetryInStep } from './model-call-telemetry.js';
27
28
  import type {
28
29
  ModelCallStreamPart,
29
30
  ModelCallOptions,
@@ -142,6 +143,7 @@ export async function* modelCallIterator({
142
143
  includeRawChunks = false,
143
144
  timeoutAt,
144
145
  repairToolCall,
146
+ download,
145
147
  responseFormat,
146
148
  experimental_transform,
147
149
  experimental_sandbox: sandbox,
@@ -169,6 +171,7 @@ export async function* modelCallIterator({
169
171
  includeRawChunks?: boolean;
170
172
  timeoutAt?: number;
171
173
  repairToolCall?: ToolCallRepairFunction<ToolSet>;
174
+ download?: ModelCallOptions['download'];
172
175
  responseFormat?: LanguageModelV4CallOptions['responseFormat'];
173
176
  experimental_transform?:
174
177
  | StreamTextTransform<ToolSet>
@@ -218,6 +221,8 @@ export async function* modelCallIterator({
218
221
  includeRuntimeContext: telemetry?.includeRuntimeContext,
219
222
  includeToolsContext: telemetry?.includeToolsContext,
220
223
  }) as any;
224
+ const dispatchTelemetryInStep =
225
+ shouldDispatchModelCallTelemetryInStep(telemetry);
221
226
 
222
227
  while (!done) {
223
228
  // Check for abort signal
@@ -377,9 +382,18 @@ export async function* modelCallIterator({
377
382
  includeRawChunks,
378
383
  timeoutAt,
379
384
  repairToolCall,
385
+ download,
380
386
  responseFormat,
381
387
  include,
382
388
  experimental_transform,
389
+ telemetry: dispatchTelemetryInStep
390
+ ? {
391
+ functionId: telemetry?.functionId,
392
+ recordInputs: telemetry?.recordInputs,
393
+ recordOutputs: telemetry?.recordOutputs,
394
+ stepNumber,
395
+ }
396
+ : undefined,
383
397
  };
384
398
  const modelCallResult =
385
399
  mode === 'generate'
@@ -481,16 +495,22 @@ export async function* modelCallIterator({
481
495
  }
482
496
 
483
497
  const shouldProcessTools =
484
- isToolExecutionAllowed &&
485
- (toolCalls.length > 0 || providerExecutedToolResults.size > 0);
498
+ (isToolExecutionAllowed &&
499
+ (toolCalls.length > 0 || providerExecutedToolResults.size > 0)) ||
500
+ (finishReason === 'length' && providerExecutedToolResults.size > 0);
486
501
 
487
- if (hasTerminalError) {
488
- // The error crossed the durable step boundary as data. End the loop
489
- // without throwing so WorkflowAgent can preserve the existing
490
- // resolved-result contract and expose the original value.
491
- done = true;
492
- } else if (shouldProcessTools) {
502
+ if (!hasTerminalError && shouldProcessTools) {
493
503
  lastStepWasYielded = true;
504
+ // Invalid local calls still need validation error results when execution
505
+ // is disallowed. WorkflowAgent handles them without executing the tools.
506
+ const processableToolCalls = isToolExecutionAllowed
507
+ ? toolCalls
508
+ : toolCalls.filter(
509
+ toolCall =>
510
+ (!toolCall.providerExecuted && toolCall.invalid) ||
511
+ (toolCall.providerExecuted &&
512
+ providerExecutedToolResults.has(toolCall.toolCallId)),
513
+ );
494
514
 
495
515
  const {
496
516
  content: assistantContent,
@@ -523,7 +543,7 @@ export async function* modelCallIterator({
523
543
  // This allows executeTool to pass the conversation context to tool execute functions
524
544
  // Also include provider-executed tool results so they can be used instead of local execution
525
545
  const toolResults = yield {
526
- toolCalls,
546
+ toolCalls: processableToolCalls,
527
547
  tools: effectiveTools,
528
548
  messages: conversationPrompt,
529
549
  step,
@@ -538,7 +558,7 @@ export async function* modelCallIterator({
538
558
  messages: conversationPrompt,
539
559
  toolResults,
540
560
  providerExecutedToolCallIds: new Set([
541
- ...toolCalls.flatMap(toolCall =>
561
+ ...processableToolCalls.flatMap(toolCall =>
542
562
  toolCall.providerExecuted ? [toolCall.toolCallId] : [],
543
563
  ),
544
564
  ...providerExecutedToolResults.keys(),
@@ -565,13 +585,20 @@ export async function* modelCallIterator({
565
585
  );
566
586
 
567
587
  done =
588
+ !isToolExecutionAllowed ||
568
589
  stopConditionMet ||
569
590
  (!hasClientToolCalls && pendingDeferredToolCallIds.size === 0);
570
591
  } else if (
592
+ hasTerminalError ||
571
593
  mode === 'generate' ||
572
594
  finishReason === 'stop' ||
573
595
  finishReason === 'tool-calls' ||
574
- finishReason === 'length'
596
+ finishReason === 'length' ||
597
+ finishReason === 'content-filter' ||
598
+ finishReason === 'error' ||
599
+ finishReason === 'other' ||
600
+ finishReason === 'unknown' ||
601
+ !finishReason
575
602
  ) {
576
603
  // Add assistant response content to the conversation
577
604
  const { content: assistantContent } = getAssistantMessageContent(
@@ -594,21 +621,6 @@ export async function* modelCallIterator({
594
621
  );
595
622
  }
596
623
 
597
- done = true;
598
- } else if (finishReason === 'content-filter') {
599
- // Content filter triggered - stop but don't throw
600
- done = true;
601
- } else if (finishReason === 'error') {
602
- // Model error - stop but don't throw
603
- done = true;
604
- } else if (finishReason === 'other') {
605
- // Other reason - stop but don't throw
606
- done = true;
607
- } else if (finishReason === 'unknown') {
608
- // Unknown reason - stop but don't throw
609
- done = true;
610
- } else if (!finishReason) {
611
- // No finish reason - this might happen on incomplete streams
612
624
  done = true;
613
625
  } else {
614
626
  throw new Error(