From 3ea15ac287f5c5f64be1758b8cc904e6c77fd7ab Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 17:47:02 +0200 Subject: [PATCH 1/8] chore: enable oxlint complexity max 20 # Conflicts: # packages/ai-client/src/chat-client.ts # Conflicts: # packages/openai-base/src/utils/schema-converter.ts --- .changeset/oxlint-complexity.md | 34 + .oxlintrc.json | 6 + packages/ai-acp/src/adapters/compatible.ts | 605 +++-- packages/ai-acp/src/stream/translate.ts | 235 +- packages/ai-angular/src/inject-chat.ts | 79 +- .../ai-anthropic/src/adapters/text-stream.ts | 649 ++++++ packages/ai-anthropic/src/adapters/text.ts | 1004 +++------ .../ai-bedrock/src/adapters/converse-text.ts | 225 +- .../src/converse/stream-processor.ts | 243 +- packages/ai-byteplus/src/adapters/tts.ts | 62 +- packages/ai-byteplus/src/adapters/video.ts | 321 +-- packages/ai-claude-code/src/adapters/text.ts | 492 +++-- .../ai-claude-code/src/stream/translate.ts | 235 +- packages/ai-client/src/chat-client.ts | 719 +++--- packages/ai-client/src/connection-adapters.ts | 247 ++- packages/ai-client/src/generation-client.ts | 167 +- packages/ai-client/src/generation-types.ts | 153 +- packages/ai-client/src/interrupt-manager.ts | 471 ++-- .../ai-client/src/video-generation-client.ts | 128 +- .../ai-code-mode/src/create-code-mode-tool.ts | 278 +-- packages/ai-codex/src/adapters/text.ts | 205 +- packages/ai-cohere/src/adapters/embedding.ts | 72 +- .../src/components/hooks/HookDetails.tsx | 200 +- packages/ai-devtools/src/store/ai-context.tsx | 820 +++---- .../ai-devtools/src/store/hook-registry.ts | 368 ++-- .../ai-durable-stream/src/durable-stream.ts | 332 ++- .../src/adapters/transcription.ts | 95 +- .../src/devtools-middleware.ts | 236 +- packages/ai-gemini/src/adapters/image.ts | 101 +- packages/ai-gemini/src/adapters/text.ts | 771 ++++--- packages/ai-gemini/src/adapters/tts.ts | 215 +- .../experimental/text-interactions/adapter.ts | 1057 ++------- .../text-interactions/translate-events.ts | 635 ++++++ packages/ai-gemini/src/realtime/adapter.ts | 228 +- packages/ai-gemini/src/realtime/client.ts | 214 +- packages/ai-gemini/src/usage.ts | 87 +- packages/ai-grok-build/src/adapters/text.ts | 641 +++--- .../src/process/grok-acp-notifications.ts | 162 +- .../ai-grok-build/src/stream/translate.ts | 180 +- packages/ai-grok/src/adapters/video.ts | 347 +-- packages/ai-grok/src/realtime/adapter.ts | 532 +++-- packages/ai-grok/src/utils/audio.ts | 93 +- .../ai-groq/src/adapters/transcription.ts | 136 +- .../src/error-normalizer.ts | 118 +- packages/ai-mistral/src/adapters/text.ts | 772 ++++--- .../ai-mistral/src/utils/schema-converter.ts | 246 ++- packages/ai-ollama/src/adapters/text.ts | 369 ++-- .../ai-openai/src/adapters/transcription.ts | 138 +- packages/ai-openai/src/adapters/video.ts | 224 +- packages/ai-openai/src/realtime/adapter.ts | 296 ++- packages/ai-opencode/src/adapters/text.ts | 653 +++--- .../src/adapters/responses-stream.ts | 1418 ++++++++++++ .../src/adapters/responses-text.ts | 1370 +----------- .../ai-openrouter/src/adapters/text-stream.ts | 718 ++++++ packages/ai-openrouter/src/adapters/text.ts | 784 +------ packages/ai-openrouter/src/adapters/video.ts | 65 +- .../src/internal/schema-converter.ts | 120 +- packages/ai-persistence/src/middleware.ts | 1464 +++++++----- .../ai-sandbox-docker/src/sbx/materialize.ts | 67 +- packages/ai-sandbox-docker/src/sbx/policy.ts | 41 +- packages/ai-sandbox/src/bootstrap.ts | 258 ++- packages/ai-sandbox/src/checkpoint-store.ts | 322 +-- packages/ai-sandbox/src/journal-sweep.ts | 351 +-- packages/ai-sandbox/src/memory-snapshots.ts | 193 +- packages/ai-sandbox/src/middleware.ts | 1177 +++++----- packages/ai-sandbox/src/reap.ts | 178 +- packages/ai-sandbox/src/sandbox.ts | 234 +- packages/ai-sandbox/src/snapshots.ts | 274 ++- .../testkit/durable-run-fields-conformance.ts | 202 +- packages/ai-svelte/src/create-chat.svelte.ts | 85 +- packages/ai/src/activities/chat/index.ts | 1958 +++++++++-------- packages/ai/src/activities/chat/messages.ts | 716 +++--- .../src/activities/chat/middleware/compose.ts | 156 +- .../src/activities/chat/stream/processor.ts | 304 ++- .../activities/chat/tools/schema-converter.ts | 176 +- .../src/activities/chat/tools/tool-calls.ts | 768 ++++--- .../ai/src/generic-interrupt-continuation.ts | 85 +- packages/ai/src/interrupt-resume.ts | 1173 ++++++---- packages/ai/src/middlewares/otel.ts | 308 ++- packages/ai/src/stream-to-response.ts | 199 +- packages/ai/src/utilities/ag-ui-wire.ts | 365 +-- .../src/utilities/normalize-stream-chunk.ts | 123 +- .../src/adapters/chat-completions-stream.ts | 436 ++++ .../src/adapters/chat-completions-text.ts | 781 ++----- .../src/adapters/responses-stream.ts | 974 ++++++++ .../src/adapters/responses-text.ts | 1563 +++---------- .../openai-base/src/utils/schema-converter.ts | 241 +- 87 files changed, 19889 insertions(+), 16654 deletions(-) create mode 100644 .changeset/oxlint-complexity.md create mode 100644 packages/ai-anthropic/src/adapters/text-stream.ts create mode 100644 packages/ai-gemini/src/experimental/text-interactions/translate-events.ts create mode 100644 packages/ai-openrouter/src/adapters/responses-stream.ts create mode 100644 packages/ai-openrouter/src/adapters/text-stream.ts create mode 100644 packages/openai-base/src/adapters/chat-completions-stream.ts create mode 100644 packages/openai-base/src/adapters/responses-stream.ts diff --git a/.changeset/oxlint-complexity.md b/.changeset/oxlint-complexity.md new file mode 100644 index 0000000000..b192cfee9b --- /dev/null +++ b/.changeset/oxlint-complexity.md @@ -0,0 +1,34 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-acp': patch +'@tanstack/ai-angular': patch +'@tanstack/ai-anthropic': patch +'@tanstack/ai-bedrock': patch +'@tanstack/ai-byteplus': patch +'@tanstack/ai-claude-code': patch +'@tanstack/ai-client': patch +'@tanstack/ai-code-mode': patch +'@tanstack/ai-codex': patch +'@tanstack/ai-cohere': patch +'@tanstack/ai-devtools-core': patch +'@tanstack/ai-durable-stream': patch +'@tanstack/ai-elevenlabs': patch +'@tanstack/ai-event-client': patch +'@tanstack/ai-gemini': patch +'@tanstack/ai-grok': patch +'@tanstack/ai-grok-build': patch +'@tanstack/ai-groq': patch +'@tanstack/ai-isolate-quickjs': patch +'@tanstack/ai-mistral': patch +'@tanstack/ai-ollama': patch +'@tanstack/ai-openai': patch +'@tanstack/ai-opencode': patch +'@tanstack/ai-openrouter': patch +'@tanstack/ai-persistence': patch +'@tanstack/ai-sandbox': patch +'@tanstack/ai-sandbox-docker': patch +'@tanstack/ai-svelte': patch +'@tanstack/openai-base': patch +--- + +Split high-complexity functions so oxlint `complexity` can run at max 20. No public API change. diff --git a/.oxlintrc.json b/.oxlintrc.json index 89a849a54e..016febf4a2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -243,6 +243,12 @@ "selector": "TSAsExpression > TSAsExpression[typeAnnotation.type='TSUnknownKeyword']", "message": "Avoid `as unknown as ` — it bypasses TS's structural overlap check. Prefer plain `as `, fix the root cause, or opt out with `// oxlint-disable-next-line eslint-js/no-restricted-syntax -- `." } + ], + "complexity": [ + "error", + { + "max": 20 + } ] }, "plugins": ["typescript"] diff --git a/packages/ai-acp/src/adapters/compatible.ts b/packages/ai-acp/src/adapters/compatible.ts index a0bc447c76..bea499ce99 100644 --- a/packages/ai-acp/src/adapters/compatible.ts +++ b/packages/ai-acp/src/adapters/compatible.ts @@ -344,6 +344,333 @@ export class AcpCompatibleTextAdapter< resolvePermission(request, input.mode, input.bridgedToolNames) } + private resolveAcpLayout( + options: TextOptions>, + sandbox: SandboxHandle, + ) { + const modelOptions = options.modelOptions + const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR + const harnessCwd = resolveHarnessCwd(sandbox, cwd) + // This adapter does not journal yet, so a generated id is still fine. + // Routed through the helper anyway so that whenever it gains journaling + // it inherits the caller-supplied-runId requirement instead of + // re-deriving it (see `packages/ai-sandbox/src/durability.ts`). + const runId = resolveDurableRunId(options.runId, { + durable: false, + adapter: 'acp', + fallback: () => this.generateId(), + }) + const threadId = options.threadId ?? this.generateId() + return { modelOptions, cwd, harnessCwd, runId, threadId } + } + + private enforceAcpDurability( + options: TextOptions>, + logger: TextOptions['logger'], + runId: string, + ): void { + // Durability wired onto a path that cannot deliver it. Two outcomes, split + // by whether a first attempt has already run. + // + // ATTACH is fatal. `sandboxRunDriver`'s `drive()` sets `attach: true` only + // when a previous host was already streaming this run, so continuing past + // here reaches `startAcpSession` + `session.prompt(...)` and re-runs the + // agent from scratch against the workspace that attempt already mutated, + // appending its whole output to a log that still holds the first + // attempt's. This adapter has no journal to tail, no + // `awaitAttachableJournal` to refuse the attach up front, and no + // `alignedIfAttaching` to suppress the already-delivered prefix — so there + // is nothing between here and that corruption except this throw. + // + // A FRESH durable run only fails to be recoverable LATER, which an app may + // knowingly accept (it can wire `withSandbox({ runs, durability })` once at + // the middleware level and still route some runs through this adapter). So + // that is a warn, not a throw: audible, not fatal. Once per run, not per + // chunk — a per-chunk warning would be worse than none. Mirrors + // `ai-grok-build`'s `chatStreamAcp`. + const durability = options.capabilities + ? getSandboxDurability(options.capabilities, { optional: true }) + : undefined + if (durability === undefined) return + if (durability.attach) { + throw new DurableAttachNotSupportedError( + 'acp', + 'this adapter drives the harness over a bidirectional ACP ' + + 'connection and does not journal', + ) + } + logger.warn( + 'acp: sandbox durability is wired but this adapter never journals — ' + + 'this run will not be recoverable on reconnect. Use a journaling ' + + 'harness adapter for runs that must survive a host restart, or drop ' + + 'durability if these runs are not meant to.', + { runId, adapter: 'acp' }, + ) + } + + private async provisionAcpToolBridge( + options: TextOptions>, + sandbox: SandboxHandle, + channel: ReturnType, + externalSignal: AbortSignal | undefined, + ): Promise { + if (!options.tools || options.tools.length === 0) return undefined + const provisioner = + (options.capabilities + ? getToolBridgeProvisioner(options.capabilities, { optional: true }) + : undefined) ?? nodeHttpBridgeProvisioner + return provisioner.provision(options.tools, { + provider: sandbox.provider, + context: options.context, + emitCustomEvent: channel.emitCustomEvent, + ...(externalSignal ? { signal: externalSignal } : {}), + }) + } + + private async projectAcpWorkspaceServers( + options: TextOptions>, + sandbox: SandboxHandle, + ): Promise> { + const projection = options.capabilities + ? getWorkspaceProjection(options.capabilities, { optional: true }) + : undefined + if (projection === undefined) return [] + await projectAcpWorkspace(sandbox, projection, { + ...(this.harness.skillsDir !== undefined && { + skillsDir: this.harness.skillsDir, + }), + harnessName: this.name, + }) + return workspaceMcpServers(projection) + } + + private resolveAcpAuth( + modelOptions: ResolvedOptions | undefined, + ): { mode: AcpPermissionMode; authMethodId: string | undefined } { + const mode = + modelOptions?.permissionMode ?? + this.harness.permissionMode ?? + 'bypassPermissions' + const authMode = + modelOptions?.authMode ?? this.harness.authMode ?? 'api-key' + const authMethodId = + authMode === 'host' + ? undefined + : (modelOptions?.authMethodId ?? this.harness.authMethodId) + return { mode, authMethodId } + } + + private collectAcpMcpServers( + bridge: HostToolBridge | undefined, + workspaceServers: Array, + ): Array { + return [ + ...(bridge !== undefined + ? [ + { + name: bridge.name, + url: bridge.url, + headers: [ + { name: 'Authorization', value: `Bearer ${bridge.token}` }, + ], + }, + ] + : []), + ...workspaceServers, + ] + } + + private bindAcpAbort( + externalSignal: AbortSignal | undefined, + session: AcpSessionHandle, + ): (() => void) | undefined { + if (externalSignal === undefined) return undefined + const onAbort = () => void session.cancel().catch(() => undefined) + if (externalSignal.aborted) onAbort() + else externalSignal.addEventListener('abort', onAbort, { once: true }) + return onAbort + } + + private composeAcpPromptText( + options: TextOptions>, + session: AcpSessionHandle, + sessionId: string | undefined, + resumePrompt: string, + ): string { + const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + .map((p) => p.content) + .filter((c) => c.trim() !== '') + let promptText = this.applySystemPrompts( + systemPrompts, + session.resumed || sessionId === undefined + ? resumePrompt + : this.buildPrompt(options.messages, undefined).prompt, + ) + if (options.outputSchema) { + promptText = appendOutputSchemaInstruction( + promptText, + options.outputSchema, + ) + } + return promptText + } + + private startAcpPrompt( + session: AcpSessionHandle, + queue: AsyncQueue, + promptText: string, + ): void { + session + .prompt(promptText) + .then(({ stopReason, usage }) => { + queue.push({ + kind: 'done', + stopReason, + ...(usage !== undefined && { usage }), + }) + queue.end() + }) + .catch((error: unknown) => queue.fail(error)) + } + + private async *streamAcpChunks(input: { + options: TextOptions> + channel: ReturnType + queue: AsyncQueue + bridgedToolNames: ReadonlySet + threadId: string + runId: string + sandbox: SandboxHandle + cwd: string + approvalRequests: Array + }): AsyncIterable { + const { + options, + channel, + queue, + bridgedToolNames, + threadId, + runId, + sandbox, + cwd, + approvalRequests, + } = input + const wantsStructured = options.outputSchema !== undefined + let lastAssistantText = '' + let lastTextMessageId: string | undefined + let heldFinished: AdapterYieldChunk | undefined + for await (const chunk of mergeChunkStreams( + translateAcpStream(queue, { + model: this.model, + runId, + threadId, + ...(options.parentRunId !== undefined && { + parentRunId: options.parentRunId, + }), + genId: () => this.generateId(), + bridgedToolNames, + labels: { + sessionIdEvent: `${this.name}.session-id`, + // Surface non-text agent content (image/audio/resource) instead of + // dropping it — emitted as a CUSTOM `.message-content` event. + contentEvent: `${this.name}.message-content`, + ...(this.harness.planEventName !== undefined && { + planEvent: this.harness.planEventName, + }), + ...(this.harness.refusalMessage !== undefined && { + refusalMessage: this.harness.refusalMessage, + }), + }, + onAcpEvent: (event) => + options.logger.provider(`provider=${this.name} kind=${event.kind}`, { + chunk: event, + }), + }), + channel.stream, + )) { + if (wantsStructured && chunk.type === EventType.RUN_FINISHED) { + heldFinished = chunk + continue + } + if (wantsStructured) { + if (chunk.type === EventType.TEXT_MESSAGE_START) { + lastAssistantText = '' + if (typeof chunk.messageId === 'string' && chunk.messageId !== '') { + lastTextMessageId = chunk.messageId + } + } else if ( + chunk.type === EventType.TEXT_MESSAGE_CONTENT && + typeof chunk.delta === 'string' + ) { + lastAssistantText += chunk.delta + } + } + yield chunk + } + + if (options.outputSchema) { + yield* this.emitParsedStructuredOutput( + lastAssistantText, + threadId, + runId, + lastTextMessageId, + ) + } + if (heldFinished) yield heldFinished + + // Surface any pending approval requests (interactive ask-policy actions + // awaiting a client decision); the client approves and re-runs to continue. + for (const event of approvalRequests) yield event + + if (this.harness.emitDiff) { + yield* this.emitDiffChunks(sandbox, cwd, threadId, runId) + } + } + + private acpChatStreamErrorChunk( + error: unknown, + options: TextOptions>, + logger: TextOptions['logger'], + ): AdapterYieldChunk { + const err = error as Error & { code?: string } + const rawEvent = toRunErrorRawEvent(error) + logger.errors(`${this.name}.chatStream fatal`, { + error, + source: `${this.name}.chatStream`, + }) + return { + type: EventType.RUN_ERROR, + model: options.model, + timestamp: Date.now(), + message: err.message || 'Unknown error occurred', + ...(err.code !== undefined && { code: err.code }), + ...(rawEvent !== undefined && { rawEvent }), + error: { + message: err.message || 'Unknown error occurred', + ...(err.code !== undefined && { code: err.code }), + }, + } + } + + private async releaseAcpChatResources(input: { + externalSignal: AbortSignal | undefined + onAbort: (() => void) | undefined + handle: AcpSessionHandle | undefined + transport: AcpSessionTransport | undefined + bridge: HostToolBridge | undefined + }): Promise { + if (input.externalSignal !== undefined && input.onAbort !== undefined) { + input.externalSignal.removeEventListener('abort', input.onAbort) + } + // startAcpSession owns transport teardown once a handle exists (and tears + // it down itself on a failed init). Only dispose here if we opened a + // transport but never reached a session. + if (input.handle !== undefined) await input.handle.dispose() + else if (input.transport !== undefined) + await disposeTransport(input.transport) + await input.bridge?.close() + } + async *chatStream( options: TextOptions>, ): AsyncIterable { @@ -357,58 +684,9 @@ export class AcpCompatibleTextAdapter< try { const sandbox = this.sandboxFrom(options) - const modelOptions = options.modelOptions - const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR - const harnessCwd = resolveHarnessCwd(sandbox, cwd) - // This adapter does not journal yet, so a generated id is still fine. - // Routed through the helper anyway so that whenever it gains journaling - // it inherits the caller-supplied-runId requirement instead of - // re-deriving it (see `packages/ai-sandbox/src/durability.ts`). - const runId = resolveDurableRunId(options.runId, { - durable: false, - adapter: 'acp', - fallback: () => this.generateId(), - }) - const threadId = options.threadId ?? this.generateId() - - // Durability wired onto a path that cannot deliver it. Two outcomes, split - // by whether a first attempt has already run. - // - // ATTACH is fatal. `sandboxRunDriver`'s `drive()` sets `attach: true` only - // when a previous host was already streaming this run, so continuing past - // here reaches `startAcpSession` + `session.prompt(...)` and re-runs the - // agent from scratch against the workspace that attempt already mutated, - // appending its whole output to a log that still holds the first - // attempt's. This adapter has no journal to tail, no - // `awaitAttachableJournal` to refuse the attach up front, and no - // `alignedIfAttaching` to suppress the already-delivered prefix — so there - // is nothing between here and that corruption except this throw. - // - // A FRESH durable run only fails to be recoverable LATER, which an app may - // knowingly accept (it can wire `withSandbox({ runs, durability })` once at - // the middleware level and still route some runs through this adapter). So - // that is a warn, not a throw: audible, not fatal. Once per run, not per - // chunk — a per-chunk warning would be worse than none. Mirrors - // `ai-grok-build`'s `chatStreamAcp`. - const durability = options.capabilities - ? getSandboxDurability(options.capabilities, { optional: true }) - : undefined - if (durability !== undefined) { - if (durability.attach) { - throw new DurableAttachNotSupportedError( - 'acp', - 'this adapter drives the harness over a bidirectional ACP ' + - 'connection and does not journal', - ) - } - logger.warn( - 'acp: sandbox durability is wired but this adapter never journals — ' + - 'this run will not be recoverable on reconnect. Use a journaling ' + - 'harness adapter for runs that must survive a host restart, or drop ' + - 'durability if these runs are not meant to.', - { runId, adapter: 'acp' }, - ) - } + const { modelOptions, cwd, harnessCwd, runId, threadId } = + this.resolveAcpLayout(options, sandbox) + this.enforceAcpDurability(options, logger, runId) const channel = createBridgeEventChannel({ model: this.model, @@ -422,38 +700,19 @@ export class AcpCompatibleTextAdapter< sessionId, ) - // Bridge chat()-provided tools into the agent over MCP (ACP http server). const bridgedToolNames = new Set( (options.tools ?? []).map((tool) => tool.name), ) - if (options.tools && options.tools.length > 0) { - const provisioner = - (options.capabilities - ? getToolBridgeProvisioner(options.capabilities, { optional: true }) - : undefined) ?? nodeHttpBridgeProvisioner - bridge = await provisioner.provision(options.tools, { - provider: sandbox.provider, - context: options.context, - emitCustomEvent: channel.emitCustomEvent, - ...(externalSignal ? { signal: externalSignal } : {}), - }) - } - - // Project workspace skills declared via withSandbox. MCP skills ride ACP's - // native `mcpServers` (below); gitSkills are linked into `skillsDir`. - let workspaceServers: Array = [] - const projection = options.capabilities - ? getWorkspaceProjection(options.capabilities, { optional: true }) - : undefined - if (projection !== undefined) { - await projectAcpWorkspace(sandbox, projection, { - ...(this.harness.skillsDir !== undefined && { - skillsDir: this.harness.skillsDir, - }), - harnessName: this.name, - }) - workspaceServers = workspaceMcpServers(projection) - } + bridge = await this.provisionAcpToolBridge( + options, + sandbox, + channel, + externalSignal, + ) + const workspaceServers = await this.projectAcpWorkspaceServers( + options, + sandbox, + ) const ctx: AcpHarnessContext> = { sandbox, @@ -468,17 +727,7 @@ export class AcpCompatibleTextAdapter< ? await this.harness.openTransport(ctx) : await this.openStdioTransport(ctx) - const mode = - modelOptions?.permissionMode ?? - this.harness.permissionMode ?? - 'bypassPermissions' - const authMode = - modelOptions?.authMode ?? this.harness.authMode ?? 'api-key' - const authMethodId = - authMode === 'host' - ? undefined - : (modelOptions?.authMethodId ?? this.harness.authMethodId) - + const { mode, authMethodId } = this.resolveAcpAuth(modelOptions) const approvalRequests: Array = [] const permissionHandler = this.makePermissionHandler({ mode, @@ -496,22 +745,7 @@ export class AcpCompatibleTextAdapter< { provider: this.name, model: this.model }, ) - // The host tool-bridge (chat() tools) + workspace MCP skills, both over - // ACP's native MCP channel. - const mcpServers: Array = [ - ...(bridge !== undefined - ? [ - { - name: bridge.name, - url: bridge.url, - headers: [ - { name: 'Authorization', value: `Bearer ${bridge.token}` }, - ], - }, - ] - : []), - ...workspaceServers, - ] + const mcpServers = this.collectAcpMcpServers(bridge, workspaceServers) const onAcpUpdate = (update: AcpSessionUpdate) => queue.push({ kind: 'update', update }) @@ -529,141 +763,38 @@ export class AcpCompatibleTextAdapter< }) const session = handle - if (externalSignal !== undefined) { - onAbort = () => void session.cancel().catch(() => undefined) - if (externalSignal.aborted) onAbort() - else externalSignal.addEventListener('abort', onAbort, { once: true }) - } - + onAbort = this.bindAcpAbort(externalSignal, session) queue.push({ kind: 'session', sessionId: session.sessionId }) - const systemPrompts = normalizeSystemPrompts(options.systemPrompts) - .map((p) => p.content) - .filter((c) => c.trim() !== '') - let promptText = this.applySystemPrompts( - systemPrompts, - session.resumed || sessionId === undefined - ? resumePrompt - : this.buildPrompt(options.messages, undefined).prompt, + const promptText = this.composeAcpPromptText( + options, + session, + sessionId, + resumePrompt, ) - if (options.outputSchema) { - promptText = appendOutputSchemaInstruction( - promptText, - options.outputSchema, - ) - } + this.startAcpPrompt(session, queue, promptText) - session - .prompt(promptText) - .then(({ stopReason, usage }) => { - queue.push({ - kind: 'done', - stopReason, - ...(usage !== undefined && { usage }), - }) - queue.end() - }) - .catch((error: unknown) => queue.fail(error)) - - const wantsStructured = options.outputSchema !== undefined - let lastAssistantText = '' - let lastTextMessageId: string | undefined - let heldFinished: AdapterYieldChunk | undefined - for await (const chunk of mergeChunkStreams( - translateAcpStream(queue, { - model: this.model, - runId, - threadId, - ...(options.parentRunId !== undefined && { - parentRunId: options.parentRunId, - }), - genId: () => this.generateId(), - bridgedToolNames, - labels: { - sessionIdEvent: `${this.name}.session-id`, - // Surface non-text agent content (image/audio/resource) instead of - // dropping it — emitted as a CUSTOM `.message-content` event. - contentEvent: `${this.name}.message-content`, - ...(this.harness.planEventName !== undefined && { - planEvent: this.harness.planEventName, - }), - ...(this.harness.refusalMessage !== undefined && { - refusalMessage: this.harness.refusalMessage, - }), - }, - onAcpEvent: (event) => - logger.provider(`provider=${this.name} kind=${event.kind}`, { - chunk: event, - }), - }), - channel.stream, - )) { - if (wantsStructured && chunk.type === EventType.RUN_FINISHED) { - heldFinished = chunk - continue - } - if (wantsStructured) { - if (chunk.type === EventType.TEXT_MESSAGE_START) { - lastAssistantText = '' - if (typeof chunk.messageId === 'string' && chunk.messageId !== '') { - lastTextMessageId = chunk.messageId - } - } else if ( - chunk.type === EventType.TEXT_MESSAGE_CONTENT && - typeof chunk.delta === 'string' - ) { - lastAssistantText += chunk.delta - } - } - yield chunk - } - - if (options.outputSchema) { - yield* this.emitParsedStructuredOutput( - lastAssistantText, - threadId, - runId, - lastTextMessageId, - ) - } - if (heldFinished) yield heldFinished - - // Surface any pending approval requests (interactive ask-policy actions - // awaiting a client decision); the client approves and re-runs to continue. - for (const event of approvalRequests) yield event - - if (this.harness.emitDiff) { - yield* this.emitDiffChunks(sandbox, cwd, threadId, runId) - } - } catch (error: unknown) { - const err = error as Error & { code?: string } - const rawEvent = toRunErrorRawEvent(error) - logger.errors(`${this.name}.chatStream fatal`, { - error, - source: `${this.name}.chatStream`, + yield* this.streamAcpChunks({ + options, + channel, + queue, + bridgedToolNames, + threadId, + runId, + sandbox, + cwd, + approvalRequests, }) - yield { - type: EventType.RUN_ERROR, - model: options.model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - }, - } + } catch (error: unknown) { + yield this.acpChatStreamErrorChunk(error, options, logger) } finally { - if (externalSignal !== undefined && onAbort !== undefined) { - externalSignal.removeEventListener('abort', onAbort) - } - // startAcpSession owns transport teardown once a handle exists (and tears - // it down itself on a failed init). Only dispose here if we opened a - // transport but never reached a session. - if (handle !== undefined) await handle.dispose() - else if (transport !== undefined) await disposeTransport(transport) - await bridge?.close() + await this.releaseAcpChatResources({ + externalSignal, + onAbort, + handle, + transport, + bridge, + }) } } diff --git a/packages/ai-acp/src/stream/translate.ts b/packages/ai-acp/src/stream/translate.ts index b7c1945074..bdb61c333b 100644 --- a/packages/ai-acp/src/stream/translate.ts +++ b/packages/ai-acp/src/stream/translate.ts @@ -237,124 +237,165 @@ export async function* translateAcpStream( } } - function* handleUpdate( - update: AcpSessionUpdate, + function* handleAgentMessageChunk( + update: Extract, ): Generator { - if (update.sessionUpdate === 'agent_message_chunk') { - yield* closeReasoning() - // Non-text content (image / audio / resource / resource_link): surface it - // as a CUSTOM event when the harness opted in, instead of dropping it. - if (update.content.type !== 'text') { - if (labels.contentEvent !== undefined) { - yield* closeText() - yield { - type: EventType.CUSTOM, - model, - timestamp: now(), - name: labels.contentEvent, - value: { content: update.content }, - } - } - return - } - const text = - typeof update.content.text === 'string' ? update.content.text : '' - if (text === '') return - if (textMessageId === null) { - textMessageId = genId() + yield* closeReasoning() + // Non-text content (image / audio / resource / resource_link): surface it + // as a CUSTOM event when the harness opted in, instead of dropping it. + if (update.content.type !== 'text') { + if (labels.contentEvent !== undefined) { + yield* closeText() yield { - type: EventType.TEXT_MESSAGE_START, - messageId: textMessageId, + type: EventType.CUSTOM, model, timestamp: now(), - role: 'assistant', + name: labels.contentEvent, + value: { content: update.content }, } } - textContent += text + return + } + const text = + typeof update.content.text === 'string' ? update.content.text : '' + if (text === '') return + if (textMessageId === null) { + textMessageId = genId() yield { - type: EventType.TEXT_MESSAGE_CONTENT, + type: EventType.TEXT_MESSAGE_START, messageId: textMessageId, model, timestamp: now(), - delta: text, - content: textContent, - } - } else if (update.sessionUpdate === 'agent_thought_chunk') { - yield* closeText() - const thought = - typeof update.content.text === 'string' ? update.content.text : '' - if (thought === '') return - if (reasoningId === null) { - reasoningId = genId() - yield { - type: EventType.REASONING_START, - messageId: reasoningId, - model, - timestamp: now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningId, - role: 'reasoning' as const, - model, - timestamp: now(), - } + role: 'assistant', } + } + textContent += text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: textMessageId, + model, + timestamp: now(), + delta: text, + content: textContent, + } + } + + function* handleAgentThoughtChunk( + update: Extract, + ): Generator { + yield* closeText() + const thought = + typeof update.content.text === 'string' ? update.content.text : '' + if (thought === '') return + if (reasoningId === null) { + reasoningId = genId() yield { - type: EventType.REASONING_MESSAGE_CONTENT, + type: EventType.REASONING_START, messageId: reasoningId, - delta: thought, model, timestamp: now(), } - } else if (update.sessionUpdate === 'tool_call') { - yield* closeText() - yield* closeReasoning() - yield* openToolCall(update) - if (update.status === 'completed' || update.status === 'failed') { - yield* resolveToolCall(update) - } - } else if (update.sessionUpdate === 'tool_call_update') { - if (update.status === 'completed' || update.status === 'failed') { - yield* resolveToolCall(update) - } else if ( - update.status === 'in_progress' && - update.rawInput !== undefined - ) { - yield* closeText() - yield* closeReasoning() - if (!knownToolCalls.has(update.toolCallId)) { - yield* openToolCall(update) - } else { - const input = { - ...(update.title != null && { title: update.title }), - ...(typeof update.rawInput === 'object' && update.rawInput !== null - ? (update.rawInput as Record) - : { input: update.rawInput }), - } - const args = JSON.stringify(input) - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId: update.toolCallId, - model, - timestamp: now(), - delta: args, - args, - } - } - } - } else if ( - update.sessionUpdate === 'plan' && - labels.planEvent !== undefined - ) { yield { - type: EventType.CUSTOM, + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningId, + role: 'reasoning' as const, model, timestamp: now(), - name: labels.planEvent, - value: { entries: update.entries }, } } + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningId, + delta: thought, + model, + timestamp: now(), + } + } + + function* handleToolCall( + update: Extract, + ): Generator { + yield* closeText() + yield* closeReasoning() + yield* openToolCall(update) + if (update.status === 'completed' || update.status === 'failed') { + yield* resolveToolCall(update) + } + } + + function* handleInProgressToolUpdate( + update: Extract, + ): Generator { + yield* closeText() + yield* closeReasoning() + if (!knownToolCalls.has(update.toolCallId)) { + yield* openToolCall(update) + return + } + const input = { + ...(update.title != null && { title: update.title }), + ...(typeof update.rawInput === 'object' && update.rawInput !== null + ? (update.rawInput as Record) + : { input: update.rawInput }), + } + const args = JSON.stringify(input) + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: update.toolCallId, + model, + timestamp: now(), + delta: args, + args, + } + } + + function* handleToolCallUpdate( + update: Extract, + ): Generator { + if (update.status === 'completed' || update.status === 'failed') { + yield* resolveToolCall(update) + return + } + if (update.status === 'in_progress' && update.rawInput !== undefined) { + yield* handleInProgressToolUpdate(update) + } + } + + function* handlePlan( + update: Extract, + ): Generator { + if (labels.planEvent === undefined) return + yield { + type: EventType.CUSTOM, + model, + timestamp: now(), + name: labels.planEvent, + value: { entries: update.entries }, + } + } + + function* handleUpdate( + update: AcpSessionUpdate, + ): Generator { + if (update.sessionUpdate === 'agent_message_chunk') { + yield* handleAgentMessageChunk(update) + return + } + if (update.sessionUpdate === 'agent_thought_chunk') { + yield* handleAgentThoughtChunk(update) + return + } + if (update.sessionUpdate === 'tool_call') { + yield* handleToolCall(update) + return + } + if (update.sessionUpdate === 'tool_call_update') { + yield* handleToolCallUpdate(update) + return + } + if (update.sessionUpdate === 'plan') { + yield* handlePlan(update) + } } try { diff --git a/packages/ai-angular/src/inject-chat.ts b/packages/ai-angular/src/inject-chat.ts index 58a4a13502..dee9cf31f2 100644 --- a/packages/ai-angular/src/inject-chat.ts +++ b/packages/ai-angular/src/inject-chat.ts @@ -42,6 +42,32 @@ import type { const EMPTY_INTERRUPTS = Object.freeze([]) const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) +function persistenceOptions< + TTools extends ReadonlyArray, + TSchema extends SchemaInput | undefined, + TContext, + TInterrupts extends ReadonlyArray>, +>( + options: InjectChatOptions, +): + | { persistence: NonNullable; threadId: string } + | { threadId?: typeof options.threadId } { + if (typeof options.threadId === 'string' && options.persistence) { + return { persistence: options.persistence, threadId: options.threadId } + } + return options.threadId !== undefined ? { threadId: options.threadId } : {} +} + +function definedFields( + fields: Record, +): Record { + const out: Record = {} + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined) out[key] = value + } + return out +} + export function injectChat< const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, @@ -102,33 +128,9 @@ export function injectChat< const client = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - ...(options.initialMessages !== undefined && { - initialMessages: options.initialMessages, - }), - ...(typeof options.threadId === 'string' && options.persistence - ? { - persistence: options.persistence, - threadId: options.threadId, - } - : { - ...(options.threadId !== undefined && { threadId: options.threadId }), - }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), - ...(bodySource !== undefined && { body: bodySource() }), - ...(forwardedPropsSource !== undefined && { - forwardedProps: forwardedPropsSource(), - }), - ...(options.byok !== undefined && { byok: options.byok }), + ...persistenceOptions(options), byokProvider: () => options.byokProvider?.(), - ...(contextSource !== undefined && { context: contextSource() }), - devtools: { - ...options.devtools, - framework: 'angular', - hookName: 'injectChat', - outputKind: options.outputSchema ? 'structured' : 'chat', - }, + tools: options.tools, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk: StreamChunk) => options.onChunk?.(chunk), onFinish: (message) => options.onFinish?.(message), @@ -142,15 +144,8 @@ export function injectChat< interruptState.set(nextInterruptState) options.onInterruptStateChange?.(nextInterruptState, context) }, - tools: options.tools, - ...(options.interrupts !== undefined && { - interrupts: options.interrupts, - }), onCustomEvent: (eventType, data, context) => options.onCustomEvent?.(eventType, data, context), - ...(options.streamProcessor !== undefined && { - streamProcessor: options.streamProcessor, - }), onMessagesChange: (m: Array>) => messages.set(m), onLoadingChange: (v: boolean) => isLoading.set(v), onStatusChange: (v: ChatClientState) => status.set(v), @@ -158,8 +153,24 @@ export function injectChat< onSubscriptionChange: (v: boolean) => isSubscribed.set(v), onConnectionStatusChange: (v: ConnectionStatus) => connectionStatus.set(v), onSessionGeneratingChange: (v: boolean) => sessionGenerating.set(v), - ...(options.queue !== undefined && { queue: options.queue }), onQueueChange: (nextQueue: Array) => queue.set(nextQueue), + devtools: { + ...options.devtools, + framework: 'angular', + hookName: 'injectChat', + outputKind: options.outputSchema ? 'structured' : 'chat', + }, + ...definedFields({ + initialMessages: options.initialMessages, + initialResumeSnapshot: options.initialResumeSnapshot, + body: bodySource?.(), + forwardedProps: forwardedPropsSource?.(), + byok: options.byok, + context: contextSource?.(), + interrupts: options.interrupts, + streamProcessor: options.streamProcessor, + queue: options.queue, + }), }) messages.set(client.getMessages()) diff --git a/packages/ai-anthropic/src/adapters/text-stream.ts b/packages/ai-anthropic/src/adapters/text-stream.ts new file mode 100644 index 0000000000..350282223a --- /dev/null +++ b/packages/ai-anthropic/src/adapters/text-stream.ts @@ -0,0 +1,649 @@ +import { EventType } from '@tanstack/ai' +import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals' +import { getAnthropicDefaultMaxTokens } from '../model-meta' +import { buildAnthropicUsage } from '../usage' +import type { InternalLogger } from '@tanstack/ai/adapter-internals' +import type { AdapterYieldChunk, TextOptions } from '@tanstack/ai' +import type Anthropic_SDK from '@anthropic-ai/sdk' + +type AnthropicStreamEvent = Anthropic_SDK.Beta.BetaRawMessageStreamEvent + +interface ToolCallBuffer { + id: string + name: string + input: string + started: boolean +} + +interface ServerToolBuffer { + id: string + name: string + input: string +} + +interface AnthropicStreamState { + options: TextOptions + genId: () => string + logger: InternalLogger + model: string + runId: string + threadId: string + messageId: string + accumulatedContent: string + accumulatedThinking: string + accumulatedSignature: string + toolCallsMap: Map + currentToolIndex: number + currentServerTool: ServerToolBuffer | null + completedServerTools: Map + stepId: string | null + reasoningMessageId: string | null + hasClosedReasoning: boolean + hasEmittedRunStarted: boolean + hasEmittedTextMessageStart: boolean + hasEmittedRunFinished: boolean + currentBlockType: string | null +} + +type AnthropicStreamHandler = ( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +) => Generator + +function parseJsonObject(input: string): unknown { + try { + const parsed = input ? JSON.parse(input) : {} + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function* closeReasoning( + state: AnthropicStreamState, +): Generator { + if (state.reasoningMessageId && !state.hasClosedReasoning) { + state.hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: state.reasoningMessageId, + model: state.model, + timestamp: Date.now(), + } + yield { + type: EventType.REASONING_END, + messageId: state.reasoningMessageId, + model: state.model, + timestamp: Date.now(), + } + } +} + +function* handleServerToolResult( + event: Extract, + state: AnthropicStreamState, +): Generator { + const block = event.content_block + if ( + block.type !== 'web_fetch_tool_result' && + block.type !== 'web_search_tool_result' + ) { + return + } + + // The result content arrives in full at content_block_start (no + // deltas). Surface error variants so a failed fetch/search isn't + // invisible to the consumer. + const content = block.content as + | { type?: string; error_code?: string } + | Array + const errorBlock = + !Array.isArray(content) && + (content.type === 'web_fetch_tool_result_error' || + content.type === 'web_search_tool_result_error') + ? content + : null + if (errorBlock) { + state.logger.errors( + `anthropic.${block.type} error_code=${errorBlock.error_code}`, + { + toolUseId: block.tool_use_id, + blockType: block.type, + errorCode: errorBlock.error_code, + source: 'anthropic.processAnthropicStream', + }, + ) + } + + // Emit the server tool as a single provider-executed tool call, + // carrying its raw result so the evidence (e.g. web_search sources) + // round-trips into the next turn's request. The agent loop skips + // provider-executed calls, so this never triggers client execution. + const serverTool = state.completedServerTools.get(block.tool_use_id) + if (!serverTool) return + + state.completedServerTools.delete(serverTool.id) + + const parsedInput = parseJsonObject(serverTool.input) + + const serverToolMetadata = { + providerExecuted: true, + anthropic: { + serverToolType: serverTool.name, + resultBlockType: block.type, + result: content, + }, + } + + state.currentToolIndex++ + yield { + type: EventType.TOOL_CALL_START, + toolCallId: serverTool.id, + toolCallName: serverTool.name, + toolName: serverTool.name, + parentMessageId: state.messageId, + model: state.model, + timestamp: Date.now(), + index: state.currentToolIndex, + metadata: serverToolMetadata, + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: serverTool.id, + toolCallName: serverTool.name, + toolName: serverTool.name, + model: state.model, + timestamp: Date.now(), + input: parsedInput, + } + + // Text after the server tool starts a fresh message segment. + state.hasEmittedTextMessageStart = false +} + +function* handleContentBlockStart( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'content_block_start') return + + state.currentBlockType = event.content_block.type + if (event.content_block.type === 'tool_use') { + state.currentToolIndex++ + state.toolCallsMap.set(state.currentToolIndex, { + id: event.content_block.id, + name: event.content_block.name, + input: '', + started: false, + }) + return + } + if (event.content_block.type === 'server_tool_use') { + state.currentServerTool = { + id: event.content_block.id, + name: event.content_block.name, + input: '', + } + return + } + if ( + event.content_block.type === 'web_fetch_tool_result' || + event.content_block.type === 'web_search_tool_result' + ) { + yield* handleServerToolResult(event, state) + return + } + if (event.content_block.type !== 'thinking') return + + state.accumulatedThinking = '' + state.accumulatedSignature = '' + // Emit REASONING and STEP_STARTED for thinking + state.stepId = state.genId() + state.reasoningMessageId = state.genId() + + // Spec REASONING events + yield { + type: EventType.REASONING_START, + messageId: state.reasoningMessageId, + model: state.model, + timestamp: Date.now(), + } + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: state.reasoningMessageId, + role: 'reasoning' as const, + model: state.model, + timestamp: Date.now(), + } + + // Legacy STEP events (kept during transition) + yield { + type: EventType.STEP_STARTED, + stepName: state.stepId, + stepId: state.stepId, + model: state.model, + timestamp: Date.now(), + stepType: 'thinking', + } +} + +function* handleTextDelta( + event: Extract, + state: AnthropicStreamState, +): Generator { + if (event.delta.type !== 'text_delta') return + + // Close reasoning before text starts + yield* closeReasoning(state) + + // Emit TEXT_MESSAGE_START on first text content + if (!state.hasEmittedTextMessageStart) { + state.hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: state.messageId, + model: state.model, + timestamp: Date.now(), + role: 'assistant', + } + } + + const delta = event.delta.text + state.accumulatedContent += delta + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: state.messageId, + model: state.model, + timestamp: Date.now(), + delta, + content: state.accumulatedContent, + } +} + +function* handleInputJsonDelta( + event: Extract, + state: AnthropicStreamState, +): Generator { + if (event.delta.type !== 'input_json_delta') return + + // Route deltas by current block type so server_tool_use input + // never appends onto the prior client tool's buffer. + if (state.currentBlockType === 'tool_use') { + const existing = state.toolCallsMap.get(state.currentToolIndex) + if (!existing) return + + // Emit TOOL_CALL_START on first args delta + if (!existing.started) { + existing.started = true + yield { + type: EventType.TOOL_CALL_START, + toolCallId: existing.id, + toolCallName: existing.name, + toolName: existing.name, + parentMessageId: state.messageId, + model: state.model, + timestamp: Date.now(), + index: state.currentToolIndex, + } + } + + existing.input += event.delta.partial_json + + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: existing.id, + model: state.model, + timestamp: Date.now(), + delta: event.delta.partial_json, + args: existing.input, + } + return + } + + if (state.currentBlockType === 'server_tool_use' && state.currentServerTool) { + // Accumulate server tool input internally. We don't emit + // TOOL_CALL_* events: the call is executed by Anthropic, not + // by our agent loop, so surfacing it as a client tool call + // would cause downstream code to try (and fail) to run it. + state.currentServerTool.input += event.delta.partial_json + } +} + +function* handleContentBlockDelta( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'content_block_delta') return + + if (event.delta.type === 'text_delta') { + yield* handleTextDelta(event, state) + return + } + if (event.delta.type === 'thinking_delta' && state.reasoningMessageId) { + const delta = event.delta.thinking + state.accumulatedThinking += delta + + // Spec REASONING content event + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: state.reasoningMessageId, + delta, + model: state.model, + timestamp: Date.now(), + } + + // Legacy STEP event + yield { + type: EventType.STEP_FINISHED, + stepName: state.stepId || state.genId(), + stepId: state.stepId || state.genId(), + model: state.model, + timestamp: Date.now(), + delta, + content: state.accumulatedThinking, + } + return + } + if ((event.delta as { type: string }).type === 'signature_delta') { + state.accumulatedSignature += + (event.delta as { signature: string }).signature || '' + return + } + if (event.delta.type === 'input_json_delta') { + yield* handleInputJsonDelta(event, state) + } +} + +function* handleToolUseStop( + state: AnthropicStreamState, +): Generator { + const existing = state.toolCallsMap.get(state.currentToolIndex) + if (!existing) return + + // If tool call wasn't started yet (no args), start it now + if (!existing.started) { + existing.started = true + yield { + type: EventType.TOOL_CALL_START, + toolCallId: existing.id, + toolCallName: existing.name, + toolName: existing.name, + parentMessageId: state.messageId, + model: state.model, + timestamp: Date.now(), + index: state.currentToolIndex, + } + } + + // Emit TOOL_CALL_END + const parsedInput = parseJsonObject(existing.input) + + yield { + type: EventType.TOOL_CALL_END, + toolCallId: existing.id, + toolCallName: existing.name, + toolName: existing.name, + model: state.model, + timestamp: Date.now(), + input: parsedInput, + } + + // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls + state.hasEmittedTextMessageStart = false +} + +function* handleContentBlockStop( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'content_block_stop') return + + if (state.currentBlockType === 'thinking') { + // Emit signature so it can be replayed in multi-turn context + if (state.accumulatedSignature && state.stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: state.stepId, + stepId: state.stepId, + model: state.model, + timestamp: Date.now(), + delta: '', + content: state.accumulatedThinking, + signature: state.accumulatedSignature, + } + } + } else if (state.currentBlockType === 'tool_use') { + yield* handleToolUseStop(state) + } else if (state.currentBlockType === 'server_tool_use') { + if (state.currentServerTool) { + // Anthropic executes the call; we only need a breadcrumb so + // consumers (devtools, telemetry) can see what ran. + state.logger.provider( + `provider=anthropic server_tool_use name=${state.currentServerTool.name}`, + { + toolUseId: state.currentServerTool.id, + name: state.currentServerTool.name, + input: state.currentServerTool.input, + }, + ) + // Hold the call until its result block arrives so we can emit + // both together as one provider-executed tool call. + state.completedServerTools.set( + state.currentServerTool.id, + state.currentServerTool, + ) + } + state.currentServerTool = null + } else if ( + state.currentBlockType === 'web_fetch_tool_result' || + state.currentBlockType === 'web_search_tool_result' + ) { + // The model already consumed the result; error variants were + // already surfaced at content_block_start. + } else if (state.hasEmittedTextMessageStart && state.accumulatedContent) { + // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: state.messageId, + model: state.model, + timestamp: Date.now(), + } + } + state.currentBlockType = null +} + +function* handleMessageStop( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'message_stop') return + + // Close reasoning events if still open + yield* closeReasoning(state) + + // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. + // message_delta carries the real stop_reason (tool_use, end_turn, etc.), + // while message_stop is just a completion signal. + if (!state.hasEmittedRunFinished) { + yield { + type: EventType.RUN_FINISHED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + finishReason: 'stop', + } + } +} + +function* handleMaxTokensStop( + state: AnthropicStreamState, +): Generator { + // Surface a warning when the truncating cap was the + // adapter-supplied default (caller didn't pass `max_tokens`), so + // the truncation isn't silently attributed to the model "doing + // nothing" (issue #849). When the caller set `max_tokens` + // themselves, hitting it is their own deliberate ceiling. + if (state.options.modelOptions?.max_tokens == null) { + const defaultedMaxTokens = getAnthropicDefaultMaxTokens(state.model) + state.logger.warn( + `anthropic response truncated at the default max_tokens (${defaultedMaxTokens}) for model=${state.model}; pass maxTokens (or modelOptions.max_tokens) to raise the output ceiling`, + { + source: 'anthropic.processAnthropicStream', + model: state.model, + defaultedMaxTokens, + }, + ) + } + yield { + type: EventType.RUN_ERROR, + model: state.model, + timestamp: Date.now(), + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + error: { + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + }, + } +} + +function* handleMessageDelta( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'message_delta') return + if (!event.delta.stop_reason) return + + state.hasEmittedRunFinished = true + + // Close reasoning events if still open + yield* closeReasoning(state) + + switch (event.delta.stop_reason) { + case 'tool_use': { + yield { + type: EventType.RUN_FINISHED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + finishReason: 'tool_calls', + usage: buildAnthropicUsage(event.usage), + } + break + } + case 'max_tokens': { + yield* handleMaxTokensStop(state) + break + } + case 'stop_sequence': + case 'end_turn': + case 'pause_turn': + case 'refusal': + case 'model_context_window_exceeded': + case 'compaction': + default: { + // All remaining Anthropic stop_reason variants map to the + // generic "stop" finish reason — they describe *why* the + // stream ended, but for AG-UI consumers the resulting event + // shape is identical. + yield { + type: EventType.RUN_FINISHED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + finishReason: 'stop', + usage: buildAnthropicUsage(event.usage), + } + } + } +} + +const anthropicStreamHandlers: Record = { + content_block_start: handleContentBlockStart, + content_block_delta: handleContentBlockDelta, + content_block_stop: handleContentBlockStop, + message_stop: handleMessageStop, + message_delta: handleMessageDelta, +} + +export async function* processAnthropicStream( + stream: AsyncIterable, + options: TextOptions, + genId: () => string, + logger: InternalLogger, +): AsyncIterable { + const state: AnthropicStreamState = { + options, + genId, + logger, + model: options.model, + runId: options.runId ?? genId(), + threadId: options.threadId ?? genId(), + messageId: genId(), + accumulatedContent: '', + accumulatedThinking: '', + accumulatedSignature: '', + toolCallsMap: new Map(), + currentToolIndex: -1, + currentServerTool: null, + completedServerTools: new Map(), + stepId: null, + reasoningMessageId: null, + hasClosedReasoning: false, + hasEmittedRunStarted: false, + hasEmittedTextMessageStart: false, + hasEmittedRunFinished: false, + currentBlockType: null, + } + + try { + for await (const event of stream) { + logger.provider(`provider=anthropic type=${event.type}`, { + chunk: event, + }) + // Emit RUN_STARTED on first event + if (!state.hasEmittedRunStarted) { + state.hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + parentRunId: options.parentRunId, + } + } + + const handler = anthropicStreamHandlers[event.type] + if (handler) { + yield* handler(event, state) + } + } + } catch (error: unknown) { + const err = error as Error & { status?: number; code?: string } + const rawEvent = toRunErrorRawEvent(error) + + logger.errors('anthropic.processAnthropicStream fatal', { + error, + source: 'anthropic.processAnthropicStream', + }) + yield { + type: EventType.RUN_ERROR, + model: state.model, + timestamp: Date.now(), + message: err.message || 'Unknown error occurred', + code: err.code || String(err.status), + // Forward the Anthropic SDK error's `.error` response body when present. + ...(rawEvent !== undefined && { rawEvent }), + error: { + message: err.message || 'Unknown error occurred', + code: err.code || String(err.status), + }, + } + } +} diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 17a87f684a..dfb7e75e4f 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -8,6 +8,7 @@ import { readCodeExecutionSkills, } from '../tools/code-execution-tool' import { validateTextProviderOptions } from '../text/text-provider-options' +import { processAnthropicStream } from './text-stream' import { buildAnthropicUsage } from '../usage' import { createAnthropicClient, @@ -211,6 +212,132 @@ export function computeAnthropicBetas( return betas.size > 0 ? Array.from(betas) : undefined } +const ANTHROPIC_MODEL_OPTION_KEYS: Array = [ + 'cache_control', + 'container', + 'context_management', + 'effort', + 'mcp_servers', + 'output_config', + 'service_tier', + 'stop_sequences', + 'thinking', + 'tool_choice', + 'top_k', + 'temperature', + 'top_p', +] + +function copyValidAnthropicModelOptions( + modelOptions: ExternalTextProviderOptions | undefined, + logger: InternalLogger, +): Partial { + const validProviderOptions: Partial = {} + if (!modelOptions) return validProviderOptions + + // `max_tokens` is a legitimate public modelOptions field, but it is read + // via a dedicated path (defaultMaxTokens below) rather than copied into + // validProviderOptions. Exempt it from the dropped-key warning here so a + // correct `modelOptions: { max_tokens }` call doesn't log a spurious + // "dropped unknown key" error, while keeping it out of the copy loop. + const droppedKeyExemptSet = new Set([ + ...ANTHROPIC_MODEL_OPTION_KEYS, + 'max_tokens', + ]) + const droppedKeys = Object.keys(modelOptions).filter( + (key) => !droppedKeyExemptSet.has(key), + ) + if (droppedKeys.length > 0) { + // Reachable when callers cast around the public type (e.g. + // `modelOptions: { system: ... } as any`). Without this warning the + // unknown keys are silently dropped — `system` in particular was a + // previously-tested path for attaching `cache_control` and we don't + // want that to fail in production with no signal. + logger.errors( + `anthropic.mapCommonOptionsToAnthropic dropped unknown modelOptions key(s): ${droppedKeys.join(', ')}`, + { + source: 'anthropic.mapCommonOptionsToAnthropic', + droppedKeys, + hint: droppedKeys.includes('system') + ? 'pass system prompts via the top-level `systemPrompts` option; `modelOptions.system` is no longer honored' + : undefined, + }, + ) + } + for (const key of ANTHROPIC_MODEL_OPTION_KEYS) { + if (!(key in modelOptions)) continue + const value = modelOptions[key] + if (key === 'tool_choice' && typeof value === 'string') { + ;(validProviderOptions as Record)[key] = { + type: value, + } + } else { + ;(validProviderOptions as Record)[key] = value + } + } + return validProviderOptions +} + +function buildAnthropicSystemBlocks( + systemPrompts: TextOptions['systemPrompts'], +): Array | undefined { + const normalized = + normalizeSystemPrompts(systemPrompts) + if (normalized.length === 0) return undefined + return normalized.map( + (p): TextBlockParam => ({ + type: 'text', + text: p.content, + ...(p.metadata?.cache_control && { + cache_control: p.metadata.cache_control, + }), + }), + ) +} + +function applyCodeExecutionSkills( + tools: Array | undefined, + validProviderOptions: Partial, +): void { + const toolSkills = tools + ?.map((tool) => + getAnthropicProviderToolKind(tool) === 'code_execution' + ? readCodeExecutionSkills(tool) + : undefined, + ) + .find((skills) => skills && skills.length > 0) + + if (toolSkills && toolSkills.length > 0) { + const existingContainer = validProviderOptions.container ?? undefined + validProviderOptions.container = { + id: existingContainer?.id ?? null, + skills: toolSkills, + } + } +} + +function resolveAnthropicMaxTokens( + model: string, + modelOptions: { max_tokens?: number } | undefined, + thinkingBudget: number | undefined, + stream: boolean, +): number { + // Anthropic's Messages API *requires* `max_tokens`, so we must always send a + // value. When the caller doesn't specify one, default to the resolved + // model's real output ceiling (from model-meta) rather than a low constant + // that silently truncates long responses with `stop_reason: "max_tokens"` + // (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on + // tokens actually generated, so a higher default costs nothing extra. + // For non-streaming requests (the `structuredOutput()` path) the default is + // clamped to the SDK's non-streaming-safe limit so it doesn't trip the + // "streaming required" 10-minute guard — see getAnthropicDefaultMaxTokens. + const defaultMaxTokens = + modelOptions?.max_tokens ?? getAnthropicDefaultMaxTokens(model, { stream }) + return thinkingBudget && thinkingBudget >= defaultMaxTokens + ? thinkingBudget + 1 + : defaultMaxTokens +} + /** * Configuration for Anthropic text adapter */ @@ -346,7 +473,7 @@ export class AnthropicTextAdapter< }, ) - yield* this.processAnthropicStream( + yield* processAnthropicStream( stream, options, () => generateId(this.name), @@ -484,110 +611,32 @@ export class AnthropicTextAdapter< options: TextOptions, { stream = true }: { stream?: boolean } = {}, ) { - const modelOptions = options.modelOptions - const formattedMessages = this.formatMessages(options.messages) const tools = options.tools ? convertToolsToProviderFormat(options.tools) : undefined - const validProviderOptions: Partial = {} - if (modelOptions) { - const validKeys: Array = [ - 'cache_control', - 'container', - 'context_management', - 'effort', - 'mcp_servers', - 'output_config', - 'service_tier', - 'stop_sequences', - 'thinking', - 'tool_choice', - 'top_k', - 'temperature', - 'top_p', - ] - // `max_tokens` is a legitimate public modelOptions field, but it is read - // via a dedicated path (defaultMaxTokens below) rather than copied into - // validProviderOptions. Exempt it from the dropped-key warning here so a - // correct `modelOptions: { max_tokens }` call doesn't log a spurious - // "dropped unknown key" error, while keeping it out of the copy loop. - const droppedKeyExemptSet = new Set([...validKeys, 'max_tokens']) - const droppedKeys = Object.keys(modelOptions).filter( - (key) => !droppedKeyExemptSet.has(key), - ) - if (droppedKeys.length > 0) { - // Reachable when callers cast around the public type (e.g. - // `modelOptions: { system: ... } as any`). Without this warning the - // unknown keys are silently dropped — `system` in particular was a - // previously-tested path for attaching `cache_control` and we don't - // want that to fail in production with no signal. - options.logger.errors( - `anthropic.mapCommonOptionsToAnthropic dropped unknown modelOptions key(s): ${droppedKeys.join(', ')}`, - { - source: 'anthropic.mapCommonOptionsToAnthropic', - droppedKeys, - hint: droppedKeys.includes('system') - ? 'pass system prompts via the top-level `systemPrompts` option; `modelOptions.system` is no longer honored' - : undefined, - }, - ) - } - for (const key of validKeys) { - if (key in modelOptions) { - const value = modelOptions[key] - if (key === 'tool_choice' && typeof value === 'string') { - ;(validProviderOptions as Record)[key] = { - type: value, - } - } else { - ;(validProviderOptions as Record)[key] = value - } - } - } - } + const validProviderOptions = copyValidAnthropicModelOptions( + options.modelOptions, + options.logger, + ) const thinkingBudget = validProviderOptions.thinking?.type === 'enabled' ? validProviderOptions.thinking.budget_tokens : undefined - // Anthropic's Messages API *requires* `max_tokens`, so we must always send a - // value. When the caller doesn't specify one, default to the resolved - // model's real output ceiling (from model-meta) rather than a low constant - // that silently truncates long responses with `stop_reason: "max_tokens"` - // (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on - // tokens actually generated, so a higher default costs nothing extra. - // For non-streaming requests (the `structuredOutput()` path) the default is - // clamped to the SDK's non-streaming-safe limit so it doesn't trip the - // "streaming required" 10-minute guard — see getAnthropicDefaultMaxTokens. - const defaultMaxTokens = - modelOptions?.max_tokens ?? - getAnthropicDefaultMaxTokens(this.model, { stream }) - const maxTokens = - thinkingBudget && thinkingBudget >= defaultMaxTokens - ? thinkingBudget + 1 - : defaultMaxTokens + const maxTokens = resolveAnthropicMaxTokens( + this.model, + options.modelOptions, + thinkingBudget, + stream, + ) // `InternalTextProviderOptions.system` is typed // `string | Array` (no `| undefined`), so build it // outside the literal and spread it conditionally rather than // assigning `undefined` under exactOptionalPropertyTypes. - const systemBlocks = ((): Array | undefined => { - const normalized = normalizeSystemPrompts( - options.systemPrompts, - ) - if (normalized.length === 0) return undefined - return normalized.map( - (p): TextBlockParam => ({ - type: 'text', - text: p.content, - ...(p.metadata?.cache_control && { - cache_control: p.metadata.cache_control, - }), - }), - ) - })() + const systemBlocks = buildAnthropicSystemBlocks(options.systemPrompts) // Wire engine-threaded outputSchema into Messages `output_config.format` // alongside any `tools` so the model emits tool calls during the agent // loop and a single schema-constrained JSON message on its final turn. @@ -612,21 +661,7 @@ export class AnthropicTextAdapter< // `container.skills` request param (Anthropic's required shape). Preserve any // `container.id` supplied via modelOptions for container reuse. This is the // canonical path for skills; `modelOptions.container.skills` is deprecated. - const toolSkills = options.tools - ?.map((tool) => - getAnthropicProviderToolKind(tool) === 'code_execution' - ? readCodeExecutionSkills(tool) - : undefined, - ) - .find((skills) => skills && skills.length > 0) - - if (toolSkills && toolSkills.length > 0) { - const existingContainer = validProviderOptions.container ?? undefined - validProviderOptions.container = { - id: existingContainer?.id ?? null, - skills: toolSkills, - } - } + applyCodeExecutionSkills(options.tools, validProviderOptions) // `temperature`/`top_p` arrive via `...validProviderOptions` (sourced from // `modelOptions`). `InternalTextProviderOptions` declares `system` and @@ -736,6 +771,117 @@ export class AnthropicTextAdapter< } } + private formatToolResultMessage( + message: ModelMessage, + toolCallId: string, + ): InternalTextProviderOptions['messages'][number] { + const toolContent = message.content + return { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolCallId, + content: Array.isArray(toolContent) + ? toolContent.map((part) => + this.convertContentPartToAnthropic(part), + ) + : typeof toolContent === 'string' + ? toolContent + : '', + }, + ], + } + } + + private parseToolCallInput(toolCall: { + function: { arguments?: string } + }): unknown { + try { + const parsed = toolCall.function.arguments + ? JSON.parse(toolCall.function.arguments) + : {} + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return toolCall.function.arguments + } + } + + private formatAssistantToolCallMessage( + message: ModelMessage, + toolCalls: NonNullable, + ): InternalTextProviderOptions['messages'][number] { + const contentBlocks: Array = [] + + this.appendThinkingBlocks(contentBlocks, message.thinking) + + if (message.content) { + const content = typeof message.content === 'string' ? message.content : '' + const textBlock: TextBlockParam = { + type: 'text', + text: content, + } + contentBlocks.push(textBlock) + } + + for (const toolCall of toolCalls) { + const parsedInput = this.parseToolCallInput(toolCall) + + // Provider-executed server tools (e.g. web_search) replay as the + // original `server_tool_use` + result blocks so the model still sees + // the prior evidence. Their result was captured verbatim during + // streaming (see processAnthropicStream). + const serverMeta = readAnthropicServerToolMetadata(toolCall.metadata) + if (serverMeta) { + const serverToolUseBlock: ServerToolUseBlockParam = { + type: 'server_tool_use', + id: toolCall.id, + name: serverMeta.serverToolType, + input: parsedInput, + } + contentBlocks.push(serverToolUseBlock) + contentBlocks.push(buildServerToolResultBlock(toolCall.id, serverMeta)) + continue + } + + const toolUseBlock: ToolUseBlockParam = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parsedInput, + } + contentBlocks.push(toolUseBlock) + } + + return { + role: 'assistant', + content: contentBlocks, + } + } + + private formatAssistantContentMessage( + message: ModelMessage, + ): InternalTextProviderOptions['messages'][number] { + const contentBlocks: Array = [] + this.appendThinkingBlocks(contentBlocks, message.thinking) + + if (Array.isArray(message.content)) { + for (const part of message.content) { + contentBlocks.push(this.convertContentPartToAnthropic(part)) + } + } else if (message.content) { + contentBlocks.push({ + type: 'text', + text: message.content, + }) + } + + return { + role: 'assistant', + content: contentBlocks.length > 0 ? contentBlocks : '', + } + } + private formatMessages( messages: Array, ): InternalTextProviderOptions['messages'] { @@ -745,117 +891,30 @@ export class AnthropicTextAdapter< const role = message.role if (role === 'tool' && message.toolCallId) { - const toolContent = message.content - formattedMessages.push({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: message.toolCallId, - content: Array.isArray(toolContent) - ? toolContent.map((part) => - this.convertContentPartToAnthropic(part), - ) - : typeof toolContent === 'string' - ? toolContent - : '', - }, - ], - }) + formattedMessages.push( + this.formatToolResultMessage(message, message.toolCallId), + ) continue } if (role === 'assistant' && message.toolCalls?.length) { - const contentBlocks: Array = [] - - this.appendThinkingBlocks(contentBlocks, message.thinking) - - if (message.content) { - const content = - typeof message.content === 'string' ? message.content : '' - const textBlock: TextBlockParam = { - type: 'text', - text: content, - } - contentBlocks.push(textBlock) - } - - for (const toolCall of message.toolCalls) { - let parsedInput: unknown = {} - try { - const parsed = toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = toolCall.function.arguments - } - - // Provider-executed server tools (e.g. web_search) replay as the - // original `server_tool_use` + result blocks so the model still sees - // the prior evidence. Their result was captured verbatim during - // streaming (see processAnthropicStream). - const serverMeta = readAnthropicServerToolMetadata(toolCall.metadata) - if (serverMeta) { - const serverToolUseBlock: ServerToolUseBlockParam = { - type: 'server_tool_use', - id: toolCall.id, - name: serverMeta.serverToolType, - input: parsedInput, - } - contentBlocks.push(serverToolUseBlock) - contentBlocks.push( - buildServerToolResultBlock(toolCall.id, serverMeta), - ) - continue - } - - const toolUseBlock: ToolUseBlockParam = { - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parsedInput, - } - contentBlocks.push(toolUseBlock) - } - - formattedMessages.push({ - role: 'assistant', - content: contentBlocks, - }) - + formattedMessages.push( + this.formatAssistantToolCallMessage(message, message.toolCalls), + ) continue } if (role === 'assistant') { - const contentBlocks: Array = [] - this.appendThinkingBlocks(contentBlocks, message.thinking) - - if (Array.isArray(message.content)) { - for (const part of message.content) { - contentBlocks.push(this.convertContentPartToAnthropic(part)) - } - } else if (message.content) { - contentBlocks.push({ - type: 'text', - text: message.content, - }) - } - - formattedMessages.push({ - role: 'assistant', - content: contentBlocks.length > 0 ? contentBlocks : '', - }) + formattedMessages.push(this.formatAssistantContentMessage(message)) continue } if (role === 'user' && Array.isArray(message.content)) { - const contentBlocks = message.content.map((part) => - this.convertContentPartToAnthropic(part), - ) formattedMessages.push({ role: 'user', - content: contentBlocks, + content: message.content.map((part) => + this.convertContentPartToAnthropic(part), + ), }) continue } @@ -959,547 +1018,6 @@ export class AnthropicTextAdapter< return merged } - - private async *processAnthropicStream( - stream: AsyncIterable, - options: TextOptions, - genId: () => string, - logger: InternalLogger, - ): AsyncIterable { - const model = options.model - let accumulatedContent = '' - let accumulatedThinking = '' - let accumulatedSignature = '' - const toolCallsMap = new Map< - number, - { id: string; name: string; input: string; started: boolean } - >() - let currentToolIndex = -1 - // Server-side tools share the `input_json_delta` wire format with client - // `tool_use` blocks; routing both to the same buffer corrupts client tool - // input. - let currentServerTool: { id: string; name: string; input: string } | null = - null - // Completed server tools awaiting their matching result block. Anthropic - // emits `server_tool_use` then a separate `*_tool_result` block; we hold - // the call here (keyed by id) until the result arrives so we can emit a - // single provider-executed tool call carrying the raw result for round-trip. - const completedServerTools = new Map< - string, - { id: string; name: string; input: string } - >() - - // AG-UI lifecycle tracking - const runId = options.runId ?? genId() - const threadId = options.threadId ?? genId() - const messageId = genId() - let stepId: string | null = null - let reasoningMessageId: string | null = null - let hasClosedReasoning = false - let hasEmittedRunStarted = false - let hasEmittedTextMessageStart = false - let hasEmittedRunFinished = false - // Track current content block type for proper content_block_stop handling - let currentBlockType: string | null = null - - try { - for await (const event of stream) { - logger.provider(`provider=anthropic type=${event.type}`, { - chunk: event, - }) - // Emit RUN_STARTED on first event - if (!hasEmittedRunStarted) { - hasEmittedRunStarted = true - yield { - type: EventType.RUN_STARTED, - runId, - threadId, - model, - timestamp: Date.now(), - parentRunId: options.parentRunId, - } - } - - if (event.type === 'content_block_start') { - currentBlockType = event.content_block.type - if (event.content_block.type === 'tool_use') { - currentToolIndex++ - toolCallsMap.set(currentToolIndex, { - id: event.content_block.id, - name: event.content_block.name, - input: '', - started: false, - }) - } else if (event.content_block.type === 'server_tool_use') { - currentServerTool = { - id: event.content_block.id, - name: event.content_block.name, - input: '', - } - } else if ( - event.content_block.type === 'web_fetch_tool_result' || - event.content_block.type === 'web_search_tool_result' - ) { - // The result content arrives in full at content_block_start (no - // deltas). Surface error variants so a failed fetch/search isn't - // invisible to the consumer. - const content = event.content_block.content as - | { type?: string; error_code?: string } - | Array - const errorBlock = - !Array.isArray(content) && - (content.type === 'web_fetch_tool_result_error' || - content.type === 'web_search_tool_result_error') - ? content - : null - if (errorBlock) { - logger.errors( - `anthropic.${event.content_block.type} error_code=${errorBlock.error_code}`, - { - toolUseId: event.content_block.tool_use_id, - blockType: event.content_block.type, - errorCode: errorBlock.error_code, - source: 'anthropic.processAnthropicStream', - }, - ) - } - - // Emit the server tool as a single provider-executed tool call, - // carrying its raw result so the evidence (e.g. web_search sources) - // round-trips into the next turn's request. The agent loop skips - // provider-executed calls, so this never triggers client execution. - const serverTool = completedServerTools.get( - event.content_block.tool_use_id, - ) - if (serverTool) { - completedServerTools.delete(serverTool.id) - - let parsedInput: unknown = {} - try { - const parsed = serverTool.input - ? JSON.parse(serverTool.input) - : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = {} - } - - const serverToolMetadata = { - providerExecuted: true, - anthropic: { - serverToolType: serverTool.name, - resultBlockType: event.content_block.type, - result: content, - }, - } - - currentToolIndex++ - yield { - type: EventType.TOOL_CALL_START, - toolCallId: serverTool.id, - toolCallName: serverTool.name, - toolName: serverTool.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: currentToolIndex, - metadata: serverToolMetadata, - } - yield { - type: EventType.TOOL_CALL_END, - toolCallId: serverTool.id, - toolCallName: serverTool.name, - toolName: serverTool.name, - model, - timestamp: Date.now(), - input: parsedInput, - } - - // Text after the server tool starts a fresh message segment. - hasEmittedTextMessageStart = false - } - } else if (event.content_block.type === 'thinking') { - accumulatedThinking = '' - accumulatedSignature = '' - // Emit REASONING and STEP_STARTED for thinking - stepId = genId() - reasoningMessageId = genId() - - // Spec REASONING events - yield { - type: EventType.REASONING_START, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningMessageId, - role: 'reasoning' as const, - model, - timestamp: Date.now(), - } - - // Legacy STEP events (kept during transition) - yield { - type: EventType.STEP_STARTED, - stepName: stepId, - stepId, - model, - timestamp: Date.now(), - stepType: 'thinking', - } - } - } else if (event.type === 'content_block_delta') { - if (event.delta.type === 'text_delta') { - // Close reasoning before text starts - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } - - // Emit TEXT_MESSAGE_START on first text content - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - model, - timestamp: Date.now(), - role: 'assistant', - } - } - - const delta = event.delta.text - accumulatedContent += delta - yield { - type: EventType.TEXT_MESSAGE_CONTENT, - messageId, - model, - timestamp: Date.now(), - delta, - content: accumulatedContent, - } - } else if ( - event.delta.type === 'thinking_delta' && - reasoningMessageId - ) { - const delta = event.delta.thinking - accumulatedThinking += delta - - // Spec REASONING content event - yield { - type: EventType.REASONING_MESSAGE_CONTENT, - messageId: reasoningMessageId, - delta, - model, - timestamp: Date.now(), - } - - // Legacy STEP event - yield { - type: EventType.STEP_FINISHED, - stepName: stepId || genId(), - stepId: stepId || genId(), - model, - timestamp: Date.now(), - delta, - content: accumulatedThinking, - } - } else if ( - (event.delta as { type: string }).type === 'signature_delta' - ) { - accumulatedSignature += - (event.delta as { signature: string }).signature || '' - } else if (event.delta.type === 'input_json_delta') { - // Route deltas by current block type so server_tool_use input - // never appends onto the prior client tool's buffer. - if (currentBlockType === 'tool_use') { - const existing = toolCallsMap.get(currentToolIndex) - if (existing) { - // Emit TOOL_CALL_START on first args delta - if (!existing.started) { - existing.started = true - yield { - type: EventType.TOOL_CALL_START, - toolCallId: existing.id, - toolCallName: existing.name, - toolName: existing.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: currentToolIndex, - } - } - - existing.input += event.delta.partial_json - - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId: existing.id, - model, - timestamp: Date.now(), - delta: event.delta.partial_json, - args: existing.input, - } - } - } else if ( - currentBlockType === 'server_tool_use' && - currentServerTool - ) { - // Accumulate server tool input internally. We don't emit - // TOOL_CALL_* events: the call is executed by Anthropic, not - // by our agent loop, so surfacing it as a client tool call - // would cause downstream code to try (and fail) to run it. - currentServerTool.input += event.delta.partial_json - } - } - } else if (event.type === 'content_block_stop') { - if (currentBlockType === 'thinking') { - // Emit signature so it can be replayed in multi-turn context - if (accumulatedSignature && stepId) { - yield { - type: EventType.STEP_FINISHED, - stepName: stepId, - stepId, - model, - timestamp: Date.now(), - delta: '', - content: accumulatedThinking, - signature: accumulatedSignature, - } - } - } else if (currentBlockType === 'tool_use') { - const existing = toolCallsMap.get(currentToolIndex) - if (existing) { - // If tool call wasn't started yet (no args), start it now - if (!existing.started) { - existing.started = true - yield { - type: EventType.TOOL_CALL_START, - toolCallId: existing.id, - toolCallName: existing.name, - toolName: existing.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: currentToolIndex, - } - } - - // Emit TOOL_CALL_END - let parsedInput: unknown = {} - try { - const parsed = existing.input ? JSON.parse(existing.input) : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = {} - } - - yield { - type: EventType.TOOL_CALL_END, - toolCallId: existing.id, - toolCallName: existing.name, - toolName: existing.name, - model, - timestamp: Date.now(), - input: parsedInput, - } - - // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls - hasEmittedTextMessageStart = false - } - } else if (currentBlockType === 'server_tool_use') { - if (currentServerTool) { - // Anthropic executes the call; we only need a breadcrumb so - // consumers (devtools, telemetry) can see what ran. - logger.provider( - `provider=anthropic server_tool_use name=${currentServerTool.name}`, - { - toolUseId: currentServerTool.id, - name: currentServerTool.name, - input: currentServerTool.input, - }, - ) - // Hold the call until its result block arrives so we can emit - // both together as one provider-executed tool call. - completedServerTools.set(currentServerTool.id, currentServerTool) - } - currentServerTool = null - } else if ( - currentBlockType === 'web_fetch_tool_result' || - currentBlockType === 'web_search_tool_result' - ) { - // The model already consumed the result; error variants were - // already surfaced at content_block_start. - } else { - // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) - if (hasEmittedTextMessageStart && accumulatedContent) { - yield { - type: EventType.TEXT_MESSAGE_END, - messageId, - model, - timestamp: Date.now(), - } - } - } - currentBlockType = null - } else if (event.type === 'message_stop') { - // Close reasoning events if still open - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } - - // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. - // message_delta carries the real stop_reason (tool_use, end_turn, etc.), - // while message_stop is just a completion signal. - if (!hasEmittedRunFinished) { - yield { - type: EventType.RUN_FINISHED, - runId, - threadId, - model, - timestamp: Date.now(), - finishReason: 'stop', - } - } - } else if (event.type === 'message_delta') { - if (event.delta.stop_reason) { - hasEmittedRunFinished = true - - // Close reasoning events if still open - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } - - switch (event.delta.stop_reason) { - case 'tool_use': { - yield { - type: EventType.RUN_FINISHED, - runId, - threadId, - model, - timestamp: Date.now(), - finishReason: 'tool_calls', - usage: buildAnthropicUsage(event.usage), - } - break - } - case 'max_tokens': { - // Surface a warning when the truncating cap was the - // adapter-supplied default (caller didn't pass `max_tokens`), so - // the truncation isn't silently attributed to the model "doing - // nothing" (issue #849). When the caller set `max_tokens` - // themselves, hitting it is their own deliberate ceiling. - if (options.modelOptions?.max_tokens == null) { - const defaultedMaxTokens = getAnthropicDefaultMaxTokens(model) - logger.warn( - `anthropic response truncated at the default max_tokens (${defaultedMaxTokens}) for model=${model}; pass maxTokens (or modelOptions.max_tokens) to raise the output ceiling`, - { - source: 'anthropic.processAnthropicStream', - model, - defaultedMaxTokens, - }, - ) - } - yield { - type: EventType.RUN_ERROR, - model, - timestamp: Date.now(), - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - error: { - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - }, - } - break - } - case 'stop_sequence': - case 'end_turn': - case 'pause_turn': - case 'refusal': - case 'model_context_window_exceeded': - case 'compaction': - default: { - // All remaining Anthropic stop_reason variants map to the - // generic "stop" finish reason — they describe *why* the - // stream ended, but for AG-UI consumers the resulting event - // shape is identical. - yield { - type: EventType.RUN_FINISHED, - runId, - threadId, - model, - timestamp: Date.now(), - finishReason: 'stop', - usage: buildAnthropicUsage(event.usage), - } - } - } - } - } - } - } catch (error: unknown) { - const err = error as Error & { status?: number; code?: string } - const rawEvent = toRunErrorRawEvent(error) - - logger.errors('anthropic.processAnthropicStream fatal', { - error, - source: 'anthropic.processAnthropicStream', - }) - yield { - type: EventType.RUN_ERROR, - model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - code: err.code || String(err.status), - // Forward the Anthropic SDK error's `.error` response body when present. - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - code: err.code || String(err.status), - }, - } - } - } } /** diff --git a/packages/ai-bedrock/src/adapters/converse-text.ts b/packages/ai-bedrock/src/adapters/converse-text.ts index 46021ad420..dca09b605a 100644 --- a/packages/ai-bedrock/src/adapters/converse-text.ts +++ b/packages/ai-bedrock/src/adapters/converse-text.ts @@ -46,6 +46,129 @@ import type { /** Config for the Converse adapter — same client config as the chat adapter. */ export interface BedrockConverseConfig extends BedrockClientConfig {} +function emitStructuredRunStarted( + runId: string, + threadId: string, + model: string, + parentRunId: string | undefined, +): AdapterYieldChunk { + return { + type: EventType.RUN_STARTED, + runId, + threadId, + model, + timestamp: Date.now(), + parentRunId, + } +} + +function structuredFragmentFromDelta( + ev: ConverseStreamOutput, +): string | undefined { + if (!('contentBlockDelta' in ev)) return undefined + const delta = ev.contentBlockDelta?.delta + if (!delta || !('toolUse' in delta)) return undefined + return delta.toolUse?.input +} + +function* emitStructuredTextDelta(args: { + started: boolean + messageId: string + fragment: string + accumulatedRaw: string + model: string +}): Generator { + if (args.started) { + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: args.messageId, + role: 'assistant', + model: args.model, + timestamp: Date.now(), + } + } + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: args.messageId, + delta: args.fragment, + content: args.accumulatedRaw, + model: args.model, + timestamp: Date.now(), + } +} + +function mapStructuredStopReason( + stopReason: string | undefined, +): 'stop' | 'length' | 'content_filter' { + if (stopReason === 'max_tokens') return 'length' + if (stopReason === 'content_filtered') return 'content_filter' + return 'stop' +} + +function* finishStructuredOutputStream(args: { + adapterName: string + accumulatedRaw: string + runId: string + threadId: string + model: string + finishReason: 'stop' | 'length' | 'content_filter' +}): Generator { + if (args.accumulatedRaw.length === 0) { + yield { + type: EventType.RUN_ERROR, + runId: args.runId, + model: args.model, + timestamp: Date.now(), + message: `${args.adapterName}.structuredOutputStream: response contained no content`, + code: 'empty-response', + error: { + message: `${args.adapterName}.structuredOutputStream: response contained no content`, + code: 'empty-response', + }, + } + return + } + + let parsed: unknown + try { + parsed = JSON.parse(args.accumulatedRaw) + } catch { + yield { + type: EventType.RUN_ERROR, + runId: args.runId, + model: args.model, + timestamp: Date.now(), + message: `Failed to parse structured output as JSON. Content: ${args.accumulatedRaw.slice(0, 200)}${args.accumulatedRaw.length > 200 ? '...' : ''}`, + code: 'parse-error', + error: { + message: 'Failed to parse structured output as JSON', + code: 'parse-error', + }, + } + return + } + + yield { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { + object: parsed, + raw: args.accumulatedRaw, + }, + model: args.model, + timestamp: Date.now(), + } + + yield { + type: EventType.RUN_FINISHED, + runId: args.runId, + threadId: args.threadId, + model: args.model, + timestamp: Date.now(), + finishReason: args.finishReason, + } +} + /** * Bedrock Converse text adapter. Wires the Converse translation modules (message * converter, tool converter, stream processor, structured-output forced-tool @@ -320,14 +443,12 @@ export class BedrockConverseTextAdapter< for await (const ev of stream) { if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield { - type: EventType.RUN_STARTED, + yield emitStructuredRunStarted( runId, threadId, - model: chatOptions.model, - timestamp: Date.now(), - parentRunId: chatOptions.parentRunId, - } + chatOptions.model, + chatOptions.parentRunId, + ) } // Surface in-band server/throttle/validation errors instead of @@ -335,44 +456,24 @@ export class BedrockConverseTextAdapter< throwIfConverseStreamError(ev) if ('contentBlockDelta' in ev) { - const delta = ev.contentBlockDelta?.delta - const fragment = - delta && 'toolUse' in delta ? delta.toolUse?.input : undefined + const fragment = structuredFragmentFromDelta(ev) if (fragment !== undefined) { - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - role: 'assistant', - model: chatOptions.model, - timestamp: Date.now(), - } - } + const started = !hasEmittedTextMessageStart + hasEmittedTextMessageStart = true accumulatedRaw += fragment - yield { - type: EventType.TEXT_MESSAGE_CONTENT, + yield* emitStructuredTextDelta({ + started, messageId, - delta: fragment, - content: accumulatedRaw, + fragment, + accumulatedRaw, model: chatOptions.model, - timestamp: Date.now(), - } + }) } continue } if ('messageStop' in ev) { - const stopReason = ev.messageStop?.stopReason - // The forced structured-output tool produces stopReason 'tool_use' on - // success, but that's an implementation detail — a cleanly-completed - // structured run reports 'stop', matching openai-base's contract. - finishReason = - stopReason === 'max_tokens' - ? 'length' - : stopReason === 'content_filtered' - ? 'content_filter' - : 'stop' + finishReason = mapStructuredStopReason(ev.messageStop?.stopReason) continue } } @@ -398,60 +499,14 @@ export class BedrockConverseTextAdapter< } } - if (accumulatedRaw.length === 0) { - yield { - type: EventType.RUN_ERROR, - runId, - model: chatOptions.model, - timestamp: Date.now(), - message: `${this.name}.structuredOutputStream: response contained no content`, - code: 'empty-response', - error: { - message: `${this.name}.structuredOutputStream: response contained no content`, - code: 'empty-response', - }, - } - return - } - - let parsed: unknown - try { - parsed = JSON.parse(accumulatedRaw) - } catch { - yield { - type: EventType.RUN_ERROR, - runId, - model: chatOptions.model, - timestamp: Date.now(), - message: `Failed to parse structured output as JSON. Content: ${accumulatedRaw.slice(0, 200)}${accumulatedRaw.length > 200 ? '...' : ''}`, - code: 'parse-error', - error: { - message: 'Failed to parse structured output as JSON', - code: 'parse-error', - }, - } - return - } - - yield { - type: EventType.CUSTOM, - name: 'structured-output.complete', - value: { - object: parsed, - raw: accumulatedRaw, - }, - model: chatOptions.model, - timestamp: Date.now(), - } - - yield { - type: EventType.RUN_FINISHED, + yield* finishStructuredOutputStream({ + adapterName: this.name, + accumulatedRaw, runId, threadId, model: chatOptions.model, - timestamp: Date.now(), finishReason, - } + }) } catch (error: unknown) { if (!hasEmittedRunStarted) { hasEmittedRunStarted = true diff --git a/packages/ai-bedrock/src/converse/stream-processor.ts b/packages/ai-bedrock/src/converse/stream-processor.ts index e5d2af9a01..ef18f1c35f 100644 --- a/packages/ai-bedrock/src/converse/stream-processor.ts +++ b/packages/ai-bedrock/src/converse/stream-processor.ts @@ -10,6 +10,30 @@ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' * underlying exception (these SDK members extend `Error`) so the adapter's * `chatStream` / `structuredOutputStream` catch converts it into a `RUN_ERROR`. */ +function usageFromMetadata( + ev: ConverseStreamOutput, +): + | { promptTokens: number; completionTokens: number; totalTokens: number } + | undefined { + if (!('metadata' in ev)) return undefined + const u = ev.metadata?.usage + if (!u) return undefined + return { + promptTokens: u.inputTokens ?? 0, + completionTokens: u.outputTokens ?? 0, + totalTokens: u.totalTokens ?? 0, + } +} + +function mapConverseStopReason( + stopReason: string | undefined, +): NonNullable { + if (stopReason === 'tool_use') return 'tool_calls' + if (stopReason === 'max_tokens') return 'length' + if (stopReason === 'content_filtered') return 'content_filter' + return 'stop' +} + export function throwIfConverseStreamError(ev: ConverseStreamOutput): void { if ('internalServerException' in ev && ev.internalServerException) { throw ev.internalServerException @@ -121,148 +145,145 @@ export async function* processConverseStream( } } - for await (const ev of stream) { - yield* ensureRunStarted() - - // Surface in-band server/throttle/validation errors instead of dropping them. - throwIfConverseStreamError(ev) + function* handleContentBlockStart( + ev: Extract, + ): Generator { + const start = ev.contentBlockStart + const toolUse = start?.start?.toolUse + if (!start || !toolUse) return + yield* closeReasoning() + const id = toolUse.toolUseId ?? newMessageId() + const name = toolUse.name ?? '' + const index = start.contentBlockIndex ?? 0 + toolCallsByIndex.set(index, { + id, + name, + started: true, + }) + yield { + type: EventType.TOOL_CALL_START, + toolCallId: id, + toolCallName: name, + toolName: name, + timestamp: Date.now(), + index, + } + } - // messageStart carries only the role; no AG-UI event maps to it. - if ('messageStart' in ev) continue + function* handleContentBlockDelta( + ev: Extract, + ): Generator { + const block = ev.contentBlockDelta + const delta = block?.delta + const index = block?.contentBlockIndex ?? 0 - if ('contentBlockStart' in ev) { - const start = ev.contentBlockStart - const toolUse = start?.start?.toolUse - if (start && toolUse) { - yield* closeReasoning() - const id = toolUse.toolUseId ?? newMessageId() - const name = toolUse.name ?? '' - const index = start.contentBlockIndex ?? 0 - toolCallsByIndex.set(index, { - id, - name, - started: true, - }) + if (delta && 'toolUse' in delta && delta.toolUse?.input !== undefined) { + const toolCall = toolCallsByIndex.get(index) + if (toolCall?.started) { yield { - type: EventType.TOOL_CALL_START, - toolCallId: id, - toolCallName: name, - toolName: name, + type: EventType.TOOL_CALL_ARGS, + toolCallId: toolCall.id, timestamp: Date.now(), - index, + delta: delta.toolUse.input, } } - continue + return } - if ('contentBlockDelta' in ev) { - const block = ev.contentBlockDelta - const delta = block?.delta - const index = block?.contentBlockIndex ?? 0 - - // Tool-call argument fragments (partial JSON). - if (delta && 'toolUse' in delta && delta.toolUse?.input !== undefined) { - const toolCall = toolCallsByIndex.get(index) - if (toolCall?.started) { - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId: toolCall.id, - timestamp: Date.now(), - delta: delta.toolUse.input, - } - } - continue - } - - // Reasoning content. - if ( - delta && - 'reasoningContent' in delta && - delta.reasoningContent && - 'text' in delta.reasoningContent && - delta.reasoningContent.text !== undefined - ) { - if (!reasoningMessageId) { - reasoningMessageId = newMessageId() - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningMessageId, - role: 'reasoning', - timestamp: Date.now(), - } - } + if ( + delta && + 'reasoningContent' in delta && + delta.reasoningContent && + 'text' in delta.reasoningContent && + delta.reasoningContent.text !== undefined + ) { + if (!reasoningMessageId) { + reasoningMessageId = newMessageId() yield { - type: EventType.REASONING_MESSAGE_CONTENT, + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, - delta: delta.reasoningContent.text, + role: 'reasoning', timestamp: Date.now(), } - continue } + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: delta.reasoningContent.text, + timestamp: Date.now(), + } + return + } - // Text content. - if (delta && 'text' in delta && delta.text !== undefined) { - yield* closeReasoning() - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - role: 'assistant', - timestamp: Date.now(), - } - } - accumulatedContent += delta.text + if (delta && 'text' in delta && delta.text !== undefined) { + yield* closeReasoning() + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true yield { - type: EventType.TEXT_MESSAGE_CONTENT, + type: EventType.TEXT_MESSAGE_START, messageId, - delta: delta.text, - content: accumulatedContent, + role: 'assistant', timestamp: Date.now(), } } + accumulatedContent += delta.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: delta.text, + content: accumulatedContent, + timestamp: Date.now(), + } + } + } + + function* handleContentBlockStop( + ev: Extract, + ): Generator { + const stopIndex = ev.contentBlockStop?.contentBlockIndex ?? 0 + const toolCall = toolCallsByIndex.get(stopIndex) + if (!toolCall?.started) return + yield { + type: EventType.TOOL_CALL_END, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + timestamp: Date.now(), + } + toolCallsByIndex.delete(stopIndex) + } + + for await (const ev of stream) { + yield* ensureRunStarted() + + // Surface in-band server/throttle/validation errors instead of dropping them. + throwIfConverseStreamError(ev) + + // messageStart carries only the role; no AG-UI event maps to it. + if ('messageStart' in ev) continue + + if ('contentBlockStart' in ev) { + yield* handleContentBlockStart(ev) + continue + } + + if ('contentBlockDelta' in ev) { + yield* handleContentBlockDelta(ev) continue } if ('contentBlockStop' in ev) { - const stopIndex = ev.contentBlockStop?.contentBlockIndex ?? 0 - const toolCall = toolCallsByIndex.get(stopIndex) - if (toolCall?.started) { - yield { - type: EventType.TOOL_CALL_END, - toolCallId: toolCall.id, - toolCallName: toolCall.name, - toolName: toolCall.name, - timestamp: Date.now(), - } - toolCallsByIndex.delete(stopIndex) - } + yield* handleContentBlockStop(ev) continue } if ('messageStop' in ev) { - const stopReason = ev.messageStop?.stopReason - // Map Converse stopReason to AG-UI's narrower finishReason vocabulary. - finishReason = - stopReason === 'tool_use' - ? 'tool_calls' - : stopReason === 'max_tokens' - ? 'length' - : stopReason === 'content_filtered' - ? 'content_filter' - : 'stop' + finishReason = mapConverseStopReason(ev.messageStop?.stopReason) continue } if ('metadata' in ev) { - const u = ev.metadata?.usage - if (u) { - usage = { - promptTokens: u.inputTokens ?? 0, - completionTokens: u.outputTokens ?? 0, - totalTokens: u.totalTokens ?? 0, - } - } + usage = usageFromMetadata(ev) ?? usage continue } } diff --git a/packages/ai-byteplus/src/adapters/tts.ts b/packages/ai-byteplus/src/adapters/tts.ts index 1c100585ca..37e99b467e 100644 --- a/packages/ai-byteplus/src/adapters/tts.ts +++ b/packages/ai-byteplus/src/adapters/tts.ts @@ -248,28 +248,13 @@ export function buildTTSRequestBody(options: { // rates the endpoint accepts, so relying on it is a coin flip. const sampleRate = modelOptions?.sample_rate ?? DEFAULT_SAMPLE_RATE - const audioConfig: BytePlusTTSAudioConfig = { - format: audioFormat, - sample_rate: sampleRate, - } - if (modelOptions?.pitch_rate !== undefined) { - audioConfig.pitch_rate = modelOptions.pitch_rate - } - if (modelOptions?.loudness_rate !== undefined) { - audioConfig.loudness_rate = modelOptions.loudness_rate - } - if (modelOptions?.enable_subtitle !== undefined) { - audioConfig.enable_subtitle = modelOptions.enable_subtitle - } - - // An explicit `speech_rate` always wins over the derived one — it is the - // native unit and the only way to reach the extremes precisely. - const speechRate = - modelOptions?.speech_rate ?? - (speed !== undefined ? toSpeechRate(speed, logger) : undefined) - if (speechRate !== undefined) { - audioConfig.speech_rate = speechRate - } + const audioConfig = buildTTSAudioConfig({ + audioFormat, + sampleRate, + speed, + modelOptions, + logger, + }) const body: BytePlusTTSCreateRequest = { model, @@ -309,6 +294,39 @@ export function buildTTSRequestBody(options: { * `TTSOptions.speed` spans a wider 0.25×–4× than the endpoint supports, so * anything outside 0.5×–2× clamps (and warns) rather than erroring. */ +function buildTTSAudioConfig(options: { + audioFormat: BytePlusTTSAudioFormat + sampleRate: number + speed: number | undefined + modelOptions: BytePlusTTSProviderOptions | undefined + logger: InternalLogger +}): BytePlusTTSAudioConfig { + const { audioFormat, sampleRate, speed, modelOptions, logger } = options + const audioConfig: BytePlusTTSAudioConfig = { + format: audioFormat, + sample_rate: sampleRate, + } + if (modelOptions?.pitch_rate !== undefined) { + audioConfig.pitch_rate = modelOptions.pitch_rate + } + if (modelOptions?.loudness_rate !== undefined) { + audioConfig.loudness_rate = modelOptions.loudness_rate + } + if (modelOptions?.enable_subtitle !== undefined) { + audioConfig.enable_subtitle = modelOptions.enable_subtitle + } + + // An explicit `speech_rate` always wins over the derived one — it is the + // native unit and the only way to reach the extremes precisely. + const speechRate = + modelOptions?.speech_rate ?? + (speed !== undefined ? toSpeechRate(speed, logger) : undefined) + if (speechRate !== undefined) { + audioConfig.speech_rate = speechRate + } + return audioConfig +} + export function toSpeechRate(speed: number, logger?: InternalLogger): number { const rate = Math.round((speed - 1) * 100) const clamped = Math.min(100, Math.max(-50, rate)) diff --git a/packages/ai-byteplus/src/adapters/video.ts b/packages/ai-byteplus/src/adapters/video.ts index 8f827946ac..de7ea88803 100644 --- a/packages/ai-byteplus/src/adapters/video.ts +++ b/packages/ai-byteplus/src/adapters/video.ts @@ -82,6 +82,174 @@ function mediaPartToUrl( return `data:${source.mimeType.toLowerCase()};base64,${source.value}` } +interface BytePlusMediaCounts { + firstFrames: number + lastFrames: number + visualReferences: number + audioReferences: number +} + +function appendBytePlusImageParts( + content: Array, + images: ReturnType['images'], + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + for (const part of images) { + const role = part.metadata?.role + switch (role) { + case 'mask': + case 'control': + throw new Error( + `byteplus: Seedance has no '${role}' image input on model ${model}. ` + + `Use 'start_frame', 'end_frame' or 'reference'.`, + ) + case 'end_frame': { + if (gated && !supportsLastFrame(model)) { + throw new Error( + `byteplus: ${model} does not support a closing frame — it does ` + + `text-to-video and first-frame image-to-video only. Drop the ` + + `'end_frame' image or switch to a model with first-and-last-frame support.`, + ) + } + counts.lastFrames++ + content.push({ + type: 'image_url', + image_url: { url: mediaPartToUrl(part) }, + role: 'last_frame', + }) + break + } + case 'reference': + case 'character': { + if (gated && !supportsReferenceMedia(model)) { + throw new Error( + `byteplus: ${model} does not support reference images. Reference ` + + `media is available on Seedance 2.5 and the 2.0 family; on this ` + + `model use 'start_frame' / 'end_frame' images instead.`, + ) + } + counts.visualReferences++ + content.push({ + type: 'image_url', + image_url: { url: mediaPartToUrl(part) }, + role: 'reference_image', + }) + break + } + // An un-roled image is the opening frame, matching the API's own + // default and the fal / Veo adapters' positional convention. + case 'start_frame': + case undefined: { + counts.firstFrames++ + content.push({ + type: 'image_url', + image_url: { url: mediaPartToUrl(part) }, + role: 'first_frame', + }) + break + } + } + } +} + +function appendBytePlusVideoParts( + content: Array, + videos: ReturnType['videos'], + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + for (const part of videos) { + if (gated && !supportsReferenceMedia(model)) { + throw new Error( + `byteplus: ${model} does not accept video prompt parts. Reference ` + + `video is available on Seedance 2.5 and the 2.0 family only.`, + ) + } + counts.visualReferences++ + content.push({ + type: 'video_url', + video_url: { url: mediaPartToUrl(part) }, + role: 'reference_video', + }) + } +} + +function appendBytePlusAudioParts( + content: Array, + audios: ReturnType['audios'], + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + for (const part of audios) { + if (gated && !supportsReferenceMedia(model)) { + throw new Error( + `byteplus: ${model} does not accept audio prompt parts. Reference ` + + `audio is available on Seedance 2.5 and the 2.0 family only.`, + ) + } + counts.audioReferences++ + content.push({ + type: 'audio_url', + audio_url: { url: mediaPartToUrl(part) }, + role: 'reference_audio', + }) + } +} + +function assertBytePlusModeRules( + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + if (!gated) return + const { firstFrames, lastFrames, visualReferences, audioReferences } = counts + if (firstFrames + lastFrames > 0 && visualReferences + audioReferences > 0) { + throw new Error( + `byteplus: first/last frame inputs cannot be combined with reference ` + + `media on model ${model}. Use either frame roles ('start_frame', ` + + `'end_frame') or reference roles ('reference', 'character', video, ` + + `audio) — not both.`, + ) + } + if (firstFrames > 1) { + throw new Error( + `byteplus: ${model} accepts at most one opening frame; received ` + + `${firstFrames} un-roled or 'start_frame' images. Use metadata.role ` + + `('end_frame', 'reference') to disambiguate the others.`, + ) + } + if (lastFrames > 1) { + throw new Error( + `byteplus: ${model} accepts at most one closing frame; received ` + + `${lastFrames} 'end_frame' images.`, + ) + } + // Seedance treats a closing frame as the second half of first-and-last- + // frame mode: on its own it fails with "last frame image content cannot be + // mixed with first frame or reference image content". + if (lastFrames > 0 && firstFrames === 0) { + throw new Error( + `byteplus: a closing frame needs an opening frame alongside it on ` + + `model ${model}. Add a 'start_frame' image, or drop the 'end_frame' role.`, + ) + } + if ( + audioReferences > 0 && + visualReferences === 0 && + !supportsAudioOnlyReference(model) + ) { + throw new Error( + `byteplus: a reference audio input cannot be the only reference on ` + + `model ${model}. Pair it with a reference image or video, or use ` + + `Seedance 2.5 which accepts audio-only reference input.`, + ) + } +} + /** Coerces a usage count that the API types as a string but sends as a number. */ function toTokenCount(value: number | string | undefined): number | undefined { if (typeof value === 'number') return value @@ -244,151 +412,16 @@ export class BytePlusVideoAdapter< // BytePlusVideoModelOrString). 'mask' / 'control' still throw: Seedance's // wire format has no field to carry them on any model. const gated = isKnownBytePlusVideoModel(model) - - let firstFrames = 0 - let lastFrames = 0 - // Audio counts as a reference for the mode-exclusivity check. On Seedance - // 2.0 it also needs a visual reference; 2.5 allows audio-only. - let visualReferences = 0 - let audioReferences = 0 - - for (const part of resolved.images) { - const role = part.metadata?.role - switch (role) { - case 'mask': - case 'control': - throw new Error( - `byteplus: Seedance has no '${role}' image input on model ${model}. ` + - `Use 'start_frame', 'end_frame' or 'reference'.`, - ) - case 'end_frame': { - if (gated && !supportsLastFrame(model)) { - throw new Error( - `byteplus: ${model} does not support a closing frame — it does ` + - `text-to-video and first-frame image-to-video only. Drop the ` + - `'end_frame' image or switch to a model with first-and-last-frame support.`, - ) - } - lastFrames++ - content.push({ - type: 'image_url', - image_url: { url: mediaPartToUrl(part) }, - role: 'last_frame', - }) - break - } - case 'reference': - case 'character': { - if (gated && !supportsReferenceMedia(model)) { - throw new Error( - `byteplus: ${model} does not support reference images. Reference ` + - `media is available on Seedance 2.5 and the 2.0 family; on this ` + - `model use 'start_frame' / 'end_frame' images instead.`, - ) - } - visualReferences++ - content.push({ - type: 'image_url', - image_url: { url: mediaPartToUrl(part) }, - role: 'reference_image', - }) - break - } - // An un-roled image is the opening frame, matching the API's own - // default and the fal / Veo adapters' positional convention. - case 'start_frame': - case undefined: { - firstFrames++ - content.push({ - type: 'image_url', - image_url: { url: mediaPartToUrl(part) }, - role: 'first_frame', - }) - break - } - } - } - - // Video and audio parts only exist in reference mode: Seedance rejects an - // un-roled video with "reference media mode requires video role to be - // reference_video", and has no frame-style role for either modality. - for (const part of resolved.videos) { - if (gated && !supportsReferenceMedia(model)) { - throw new Error( - `byteplus: ${model} does not accept video prompt parts. Reference ` + - `video is available on Seedance 2.5 and the 2.0 family only.`, - ) - } - visualReferences++ - content.push({ - type: 'video_url', - video_url: { url: mediaPartToUrl(part) }, - role: 'reference_video', - }) - } - - for (const part of resolved.audios) { - if (gated && !supportsReferenceMedia(model)) { - throw new Error( - `byteplus: ${model} does not accept audio prompt parts. Reference ` + - `audio is available on Seedance 2.5 and the 2.0 family only.`, - ) - } - audioReferences++ - content.push({ - type: 'audio_url', - audio_url: { url: mediaPartToUrl(part) }, - role: 'reference_audio', - }) - } - - const frames = firstFrames + lastFrames - if (gated && frames > 0 && visualReferences + audioReferences > 0) { - throw new Error( - `byteplus: first/last frame inputs cannot be combined with reference ` + - `media on model ${model}. Use either frame roles ('start_frame', ` + - `'end_frame') or reference roles ('reference', 'character', video, ` + - `audio) — not both.`, - ) - } - - if (gated && firstFrames > 1) { - throw new Error( - `byteplus: ${model} accepts at most one opening frame; received ` + - `${firstFrames} un-roled or 'start_frame' images. Use metadata.role ` + - `('end_frame', 'reference') to disambiguate the others.`, - ) - } - - if (gated && lastFrames > 1) { - throw new Error( - `byteplus: ${model} accepts at most one closing frame; received ` + - `${lastFrames} 'end_frame' images.`, - ) - } - - // Seedance treats a closing frame as the second half of first-and-last- - // frame mode: on its own it fails with "last frame image content cannot be - // mixed with first frame or reference image content". - if (gated && lastFrames > 0 && firstFrames === 0) { - throw new Error( - `byteplus: a closing frame needs an opening frame alongside it on ` + - `model ${model}. Add a 'start_frame' image, or drop the 'end_frame' role.`, - ) - } - - if ( - gated && - audioReferences > 0 && - visualReferences === 0 && - !supportsAudioOnlyReference(model) - ) { - throw new Error( - `byteplus: a reference audio input cannot be the only reference on ` + - `model ${model}. Pair it with a reference image or video, or use ` + - `Seedance 2.5 which accepts audio-only reference input.`, - ) + const counts = { + firstFrames: 0, + lastFrames: 0, + visualReferences: 0, + audioReferences: 0, } + appendBytePlusImageParts(content, resolved.images, model, gated, counts) + appendBytePlusVideoParts(content, resolved.videos, model, gated, counts) + appendBytePlusAudioParts(content, resolved.audios, model, gated, counts) + assertBytePlusModeRules(model, gated, counts) if (content.length === 0) { throw new Error( diff --git a/packages/ai-claude-code/src/adapters/text.ts b/packages/ai-claude-code/src/adapters/text.ts index c69b575b14..79d9f584e1 100644 --- a/packages/ai-claude-code/src/adapters/text.ts +++ b/packages/ai-claude-code/src/adapters/text.ts @@ -135,6 +135,36 @@ function hostClaudeAuthEnv(): Record { * Windows Node often has USERPROFILE but no HOME. Claude then cannot find * `~/.claude.json` (the `claude login` file) and prints "Not logged in". */ +function chatRunErrorChunk(error: unknown, model: string): AdapterYieldChunk { + const err = error as Error & { code?: string } + const rawEvent = toRunErrorRawEvent(error) + const message = err.message || 'Unknown error occurred' + return { + type: EventType.RUN_ERROR, + model, + timestamp: Date.now(), + message, + ...(err.code !== undefined && { code: err.code }), + ...(rawEvent !== undefined && { rawEvent }), + error: { + message, + ...(err.code !== undefined && { code: err.code }), + }, + } +} + +function abortSignalFields( + options: TextOptions, +): { signal: AbortSignal } | Record { + if (options.abortController?.signal) { + return { signal: options.abortController.signal } + } + if (options.request?.signal) { + return { signal: options.request.signal } + } + return {} +} + function localProcessHomeEnv(provider: string): Record { if (provider !== 'local-process') return {} if (process.env.HOME) return {} @@ -253,6 +283,27 @@ export class ClaudeCodeTextAdapter< for (const dir of config.addDirs ?? []) args.push('--add-dir', dir) + this.pushClaudeToolFlags(args, options, policyFlags) + this.pushClaudeSystemPromptFlags(args, options) + + if (mcpConfigPath !== undefined) args.push('--mcp-config', mcpConfigPath) + if (hasJsonSchema) { + args.push('--json-schema', CLAUDE_JSON_SCHEMA_PLACEHOLDER) + } + if (permissionPromptTool !== undefined) { + args.push('--permission-prompt-tool', permissionPromptTool) + } + + return args + } + + private pushClaudeToolFlags( + args: Array, + options: TextOptions, + policyFlags: ClaudePolicyFlags, + ): void { + const config = this.adapterConfig + const modelOptions = options.modelOptions const allowedTools = [ ...(modelOptions?.allowedTools ?? config.allowedTools ?? []), ...policyFlags.allowedTools, @@ -267,28 +318,21 @@ export class ClaudeCodeTextAdapter< if (disallowedTools.length > 0) { args.push('--disallowedTools', [...new Set(disallowedTools)].join(',')) } + } + private pushClaudeSystemPromptFlags( + args: Array, + options: TextOptions, + ): void { const systemPrompts = normalizeSystemPrompts(options.systemPrompts) .map((prompt) => prompt.content) .filter((content) => content.trim() !== '') - if (systemPrompts.length > 0) { - const joined = systemPrompts.join('\n\n') - const flag = - config.systemPromptMode === 'replace' - ? '--system-prompt' - : '--append-system-prompt' - args.push(flag, joined) - } - - if (mcpConfigPath !== undefined) args.push('--mcp-config', mcpConfigPath) - if (hasJsonSchema) { - args.push('--json-schema', CLAUDE_JSON_SCHEMA_PLACEHOLDER) - } - if (permissionPromptTool !== undefined) { - args.push('--permission-prompt-tool', permissionPromptTool) - } - - return args + if (systemPrompts.length === 0) return + const flag = + this.adapterConfig.systemPromptMode === 'replace' + ? '--system-prompt' + : '--append-system-prompt' + args.push(flag, systemPrompts.join('\n\n')) } /** @@ -358,6 +402,213 @@ export class ClaudeCodeTextAdapter< } } + private claudePermissionTool( + policy: SandboxPolicy | undefined, + options: TextOptions, + scripts: Record | undefined, + approvalRequests: Array, + threadId: string, + runId: string, + ): + | { + toolName: string + resolve: (input: { + tool_name?: string + input?: unknown + }) => PermissionToolResult + } + | undefined { + if (policy === undefined) return undefined + return { + toolName: 'approval_prompt', + resolve: this.buildPermissionResolver( + policy, + options.approvals, + scripts, + approvalRequests, + threadId, + runId, + ), + } + } + + private async maybeProvisionClaudeBridge( + options: TextOptions, + sandbox: SandboxHandle, + channel: BridgeEventChannel, + permission: + | { + toolName: string + resolve: (input: { + tool_name?: string + input?: unknown + }) => PermissionToolResult + } + | undefined, + ): Promise { + const hasTools = options.tools !== undefined && options.tools.length > 0 + if (!hasTools && permission === undefined) return undefined + const provisioner = + (options.capabilities + ? getToolBridgeProvisioner(options.capabilities, { optional: true }) + : undefined) ?? nodeHttpBridgeProvisioner + return await provisioner.provision(options.tools ?? [], { + provider: sandbox.provider, + context: options.context, + emitCustomEvent: channel.emitCustomEvent, + ...(permission !== undefined ? { permission } : {}), + ...(options.abortController?.signal + ? { signal: options.abortController.signal } + : {}), + }) + } + + private async writeClaudeRunFiles(args: { + sandbox: SandboxHandle + cwd: string + runIdSegment: string + bridge: HostToolBridge | undefined + permission: + | { + toolName: string + resolve: (input: { + tool_name?: string + input?: unknown + }) => PermissionToolResult + } + | undefined + options: TextOptions + resume: string | undefined + policy: SandboxPolicy | undefined + prompt: string + tempFiles: Array + }): Promise<{ runCommand: string; stdinInput: string | undefined }> { + const { + sandbox, + cwd, + runIdSegment, + bridge, + permission, + options, + resume, + policy, + prompt, + tempFiles, + } = args + let mcpConfigArg: string | undefined + if (bridge) { + const mcpConfigFile = `.tanstack-mcp-bridge-${runIdSegment}.json` + const mcpConfigPath = `${cwd}/${mcpConfigFile}` + await sandbox.fs.write(mcpConfigPath, bridgeToMcpConfig(bridge)) + tempFiles.push(mcpConfigPath) + mcpConfigArg = mcpConfigFile + } + let jsonSchemaFile: string | undefined + if (options.outputSchema !== undefined) { + jsonSchemaFile = `tanstack-output-schema-${runIdSegment}.json` + const schemaPath = `${cwd}/${jsonSchemaFile}` + await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) + tempFiles.push(schemaPath) + } + const runnerFile = `tanstack-claude-run-${runIdSegment}.mjs` + await sandbox.fs.write(`${cwd}/${runnerFile}`, CLAUDE_RUNNER_SOURCE) + tempFiles.push(`${cwd}/${runnerFile}`) + const argv = this.buildArgv( + options, + resume, + mapPolicyToClaudeFlags(policy), + mcpConfigArg, + bridge && permission + ? `mcp__${bridge.name}__${permission.toolName}` + : undefined, + jsonSchemaFile !== undefined, + ) + const argvFile = `tanstack-claude-argv-${runIdSegment}.json` + await sandbox.fs.write(`${cwd}/${argvFile}`, JSON.stringify(argv)) + tempFiles.push(`${cwd}/${argvFile}`) + const command = + jsonSchemaFile === undefined + ? `node ${q(runnerFile)} ${q(argvFile)}` + : `node ${q(runnerFile)} ${q(argvFile)} ${q(jsonSchemaFile)}` + if (sandbox.capabilities.writableStdin !== false) { + return { runCommand: command, stdinInput: prompt } + } + const promptPath = `/tmp/tanstack-claude-prompt-${runIdSegment}` + await sandbox.fs.write(promptPath, prompt) + tempFiles.push(promptPath) + return { + runCommand: `${command} < ${q(promptPath)}`, + stdinInput: undefined, + } + } + + private claudeSpawnEnv( + sandbox: SandboxHandle, + options: TextOptions, + ): Record { + const authMode = + options.modelOptions?.authMode ?? this.adapterConfig.authMode ?? 'api-key' + return { + ...(sandbox.provider === 'local-process' + ? {} + : { + IS_SANDBOX: '1', + CLAUDE_CODE_SANDBOXED: '1', + }), + ...(authMode === 'api-key' ? hostClaudeAuthEnv() : {}), + ...localProcessHomeEnv(sandbox.provider), + ...this.adapterConfig.env, + } + } + + private spawnClaudeNdjson( + options: TextOptions, + sandbox: SandboxHandle, + cwd: string, + prepared: { runCommand: string; stdinInput: string | undefined }, + durability: ReturnType | undefined, + runId: string, + ) { + const journalOptions = journalOptionsFor(durability, runId) + return spawnNdjson(sandbox, prepared.runCommand, { + cwd, + ...(prepared.stdinInput !== undefined + ? { input: prepared.stdinInput } + : {}), + env: this.claudeSpawnEnv(sandbox, options), + ...abortSignalFields(options), + onNonJsonLine: (line) => + options.logger.provider(`provider=claude-code non-json line: ${line}`, { + chunk: line, + }), + ...(journalOptions === undefined ? {} : { journal: journalOptions }), + }) + } + + private async *emitClaudeDiff( + sandbox: SandboxHandle, + cwd: string, + threadId: string, + runId: string, + ): AsyncIterable { + if (this.adapterConfig.emitDiff === false) return + try { + const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { cwd }) + if (diff.exitCode === 0 && diff.stdout.trim() !== '') { + yield { + type: EventType.CUSTOM, + name: 'file.changed', + value: { path: '.', diff: diff.stdout }, + timestamp: Date.now(), + threadId, + runId, + } + } + } catch { + // not a git repo / git unavailable — skip the diff event + } + } + async *chatStream( options: TextOptions, ): AsyncIterable { @@ -420,42 +671,20 @@ export class ClaudeCodeTextAdapter< const policy = options.capabilities ? getSandboxPolicy(options.capabilities, { optional: true }) : undefined - - // A permission-prompt tool gates the agent's native tools when a policy - // can `ask`/`deny` (interactive approvals). - const permission = - policy !== undefined - ? { - toolName: 'approval_prompt', - resolve: this.buildPermissionResolver( - policy, - options.approvals, - projection?.scripts, - approvalRequests, - threadId, - runId, - ), - } - : undefined - - // Bridge chat()-provided server tools (and/or the permission tool) into - // the sandbox over MCP. - const hasTools = options.tools !== undefined && options.tools.length > 0 - if (hasTools || permission !== undefined) { - const provisioner = - (options.capabilities - ? getToolBridgeProvisioner(options.capabilities, { optional: true }) - : undefined) ?? nodeHttpBridgeProvisioner - bridge = await provisioner.provision(options.tools ?? [], { - provider: sandbox.provider, - context: options.context, - emitCustomEvent: channel.emitCustomEvent, - ...(permission !== undefined ? { permission } : {}), - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : {}), - }) - } + const permission = this.claudePermissionTool( + policy, + options, + projection?.scripts, + approvalRequests, + threadId, + runId, + ) + bridge = await this.maybeProvisionClaudeBridge( + options, + sandbox, + channel, + permission, + ) const built = buildPrompt( options.messages, @@ -475,114 +704,32 @@ export class ClaudeCodeTextAdapter< // `journalPaths` uses, so these files and the journal agree on how a given // id spells. Computed once so the two filenames cannot drift apart. const runIdSegment = encodeRunId(runId) - - // The bridge MCP config carries the per-run bearer token. Write it to a - // file and pass claude the PATH, so the token never appears in argv (where - // any process in the sandbox could read it via `ps` / `/proc//cmdline`). - let mcpConfigArg: string | undefined - if (bridge) { - // Pass claude a path RELATIVE to its cwd (the real workdir the handle - // runs the process in). An absolute VIRTUAL path like `/workspace/…` is - // wrong wherever claude runs outside a sandbox that literally uses - // `/workspace` — e.g. local-process on Windows, where git-bash resolves - // `/workspace` to `C:\Program Files\Git\workspace` and the file is "not - // found". The bare filename resolves correctly on every provider. - const mcpConfigFile = `.tanstack-mcp-bridge-${runIdSegment}.json` - const mcpConfigPath = `${cwd}/${mcpConfigFile}` - await sandbox.fs.write(mcpConfigPath, bridgeToMcpConfig(bridge)) - tempFiles.push(mcpConfigPath) - mcpConfigArg = mcpConfigFile - } - let jsonSchemaFile: string | undefined - if (options.outputSchema !== undefined) { - jsonSchemaFile = `tanstack-output-schema-${runIdSegment}.json` - const schemaPath = `${cwd}/${jsonSchemaFile}` - await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) - tempFiles.push(schemaPath) - } - const runnerFile = `tanstack-claude-run-${runIdSegment}.mjs` - await sandbox.fs.write(`${cwd}/${runnerFile}`, CLAUDE_RUNNER_SOURCE) - tempFiles.push(`${cwd}/${runnerFile}`) - const argv = this.buildArgv( + const prepared = await this.writeClaudeRunFiles({ + sandbox, + cwd, + runIdSegment, + bridge, + permission, options, resume, - mapPolicyToClaudeFlags(policy), - mcpConfigArg, - bridge && permission - ? `mcp__${bridge.name}__${permission.toolName}` - : undefined, - jsonSchemaFile !== undefined, - ) - // Filenames only on the shell line. JSON (schema, system prompt, MCP - // config path) lives in the argv file so git-bash cannot retokenize it. - const argvFile = `tanstack-claude-argv-${runIdSegment}.json` - await sandbox.fs.write(`${cwd}/${argvFile}`, JSON.stringify(argv)) - tempFiles.push(`${cwd}/${argvFile}`) - const command = - jsonSchemaFile === undefined - ? `node ${q(runnerFile)} ${q(argvFile)}` - : `node ${q(runnerFile)} ${q(argvFile)} ${q(jsonSchemaFile)}` - - // Deliver the prompt. The default feeds it over stdin (keeps it out of - // argv). Providers without a writable host→process stdin (e.g. Cloudflare) - // can't accept that write, so write the prompt to a file and redirect the - // CLI's stdin from it in-shell (`claude -p … < file`) — still out of argv. - let runCommand = command - let stdinInput: string | undefined = prompt - if (sandbox.capabilities.writableStdin === false) { - const promptPath = `/tmp/tanstack-claude-prompt-${runIdSegment}` - await sandbox.fs.write(promptPath, prompt) - tempFiles.push(promptPath) - runCommand = `${command} < ${q(promptPath)}` - stdinInput = undefined - } + policy, + prompt, + tempFiles, + }) logger.request( `activity=chat provider=claude-code model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${resume ?? 'none'}`, { provider: 'claude-code', model: this.model }, ) - const authMode = - options.modelOptions?.authMode ?? - this.adapterConfig.authMode ?? - 'api-key' - const injectApiKey = authMode === 'api-key' - - const journalOptions = journalOptionsFor(durability, runId) - const rawEvents = spawnNdjson(sandbox, runCommand, { + const rawEvents = this.spawnClaudeNdjson( + options, + sandbox, cwd, - ...(stdinInput !== undefined ? { input: stdinInput } : {}), - // Isolated sandboxes often run as root. Claude refuses - // `--dangerously-skip-permissions` as root unless IS_SANDBOX=1. - // CLAUDE_CODE_SANDBOXED marks a real isolation boundary. Do not set - // either on local-process: that provider runs on the host. - env: { - ...(sandbox.provider === 'local-process' - ? {} - : { - IS_SANDBOX: '1', - CLAUDE_CODE_SANDBOXED: '1', - }), - ...(injectApiKey ? hostClaudeAuthEnv() : {}), - ...localProcessHomeEnv(sandbox.provider), - ...this.adapterConfig.env, - }, - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : options.request?.signal - ? { signal: options.request.signal } - : {}), - onNonJsonLine: (line) => - logger.provider(`provider=claude-code non-json line: ${line}`, { - chunk: line, - }), - // Journal + attach both come from the sandbox durability capability, so - // the attach route configures takeover by passing `attach: true` to - // `withSandbox` — `chat()` stays free of sandbox vocabulary. Omitted - // entirely (not passed as `undefined`) when the run isn't durable, so - // `spawnNdjson` takes its original, unjournaled path byte-for-byte. - ...(journalOptions === undefined ? {} : { journal: journalOptions }), - }) + prepared, + durability, + runId, + ) async function* asMessages(): AsyncIterable { for await (const event of rawEvents) yield event as AgentSdkMessage @@ -627,49 +774,14 @@ export class ClaudeCodeTextAdapter< logger, ) - // Surface the working-tree diff so UIs can render what the agent changed. - if (this.adapterConfig.emitDiff !== false) { - try { - const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { - cwd, - }) - if (diff.exitCode === 0 && diff.stdout.trim() !== '') { - yield { - type: EventType.CUSTOM, - name: 'file.changed', - value: { path: '.', diff: diff.stdout }, - timestamp: Date.now(), - threadId, - runId, - } - } - } catch { - // not a git repo / git unavailable — skip the diff event - } - } - - // Surface any pending approval requests (policy `ask` actions awaiting a - // client decision); the client approves and re-runs to continue. + yield* this.emitClaudeDiff(sandbox, cwd, threadId, runId) for (const event of approvalRequests) yield event } catch (error: unknown) { - const err = error as Error & { code?: string } - const rawEvent = toRunErrorRawEvent(error) logger.errors('claude-code.chatStream fatal', { error, source: 'claude-code.chatStream', }) - yield { - type: EventType.RUN_ERROR, - model: options.model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - }, - } + yield chatRunErrorChunk(error, options.model) } finally { channel?.close() if (bridge) await bridge.close() diff --git a/packages/ai-claude-code/src/stream/translate.ts b/packages/ai-claude-code/src/stream/translate.ts index 2393e5052f..8ebce3b21f 100644 --- a/packages/ai-claude-code/src/stream/translate.ts +++ b/packages/ai-claude-code/src/stream/translate.ts @@ -450,117 +450,150 @@ export async function* translateSdkStream( } } - function* handleStreamEvent( - message: SdkPartialAssistantMessage, - ): Generator { - const event = message.event - if (event.type === 'message_start') { - partialMessageId = event.message.id ?? genId() - streamedMessageIds.add(partialMessageId) - } else if (event.type === 'content_block_start') { - partialBlockType = event.content_block.type - const startedBlock = event.content_block - partialIsStructuredOutput = - ctx.expectStructuredOutput === true && - startedBlock.type === 'tool_use' && - 'name' in startedBlock && - startedBlock.name === SYNTHETIC_STRUCTURED_OUTPUT_TOOL - if (partialIsStructuredOutput) { - partialStructuredJson = '' - if ('id' in startedBlock && typeof startedBlock.id === 'string') { - syntheticOutputToolIds.add(startedBlock.id) - } - if ('input' in startedBlock) { - capturedStructuredOutput = rememberStructuredOutput( - capturedStructuredOutput, - startedBlock.input, - ) - } + function* handleContentBlockStart(startedBlock: { + type: string + id?: string + name?: string + input?: unknown + }): Generator { + partialBlockType = startedBlock.type + partialIsStructuredOutput = + ctx.expectStructuredOutput === true && + startedBlock.type === 'tool_use' && + 'name' in startedBlock && + startedBlock.name === SYNTHETIC_STRUCTURED_OUTPUT_TOOL + if (partialIsStructuredOutput) { + partialStructuredJson = '' + if ('id' in startedBlock && typeof startedBlock.id === 'string') { + syntheticOutputToolIds.add(startedBlock.id) } - if (partialBlockType === 'text') { - partialTextMessageId = partialMessageId ?? genId() - partialTextContent = '' - if (!partialTextStarted) { - partialTextStarted = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId: partialTextMessageId, - model, - timestamp: now(), - role: 'assistant', - } - } - } else if (partialBlockType === 'thinking') { - partialReasoningId = genId() - yield { - type: EventType.REASONING_START, - messageId: partialReasoningId, - model, - timestamp: now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: partialReasoningId, - role: 'reasoning' as const, - model, - timestamp: now(), - } + if ('input' in startedBlock) { + capturedStructuredOutput = rememberStructuredOutput( + capturedStructuredOutput, + startedBlock.input, + ) } - } else if (event.type === 'content_block_delta') { - if ( - event.delta.type === 'text_delta' && - partialTextStarted && - partialTextMessageId && - typeof event.delta.text === 'string' - ) { - partialTextContent += event.delta.text - assistantTextForHarvest += event.delta.text + } + if (partialBlockType === 'text') { + partialTextMessageId = partialMessageId ?? genId() + partialTextContent = '' + if (!partialTextStarted) { + partialTextStarted = true yield { - type: EventType.TEXT_MESSAGE_CONTENT, + type: EventType.TEXT_MESSAGE_START, messageId: partialTextMessageId, model, timestamp: now(), - delta: event.delta.text, - content: partialTextContent, - } - } else if ( - event.delta.type === 'input_json_delta' && - partialIsStructuredOutput && - typeof event.delta.partial_json === 'string' - ) { - partialStructuredJson += event.delta.partial_json - } else if ( - event.delta.type === 'thinking_delta' && - partialReasoningId && - typeof event.delta.thinking === 'string' - ) { - yield { - type: EventType.REASONING_MESSAGE_CONTENT, - messageId: partialReasoningId, - delta: event.delta.thinking, - model, - timestamp: now(), + role: 'assistant', } } - } else if (event.type === 'content_block_stop') { - if (partialIsStructuredOutput && partialStructuredJson !== '') { - try { - capturedStructuredOutput = rememberStructuredOutput( - capturedStructuredOutput, - JSON.parse(partialStructuredJson), - ) - } catch { - // Incomplete JSON; the complete assistant tool_use may still arrive. - } + return + } + if (partialBlockType === 'thinking') { + partialReasoningId = genId() + yield { + type: EventType.REASONING_START, + messageId: partialReasoningId, + model, + timestamp: now(), } - if (partialBlockType === 'text') { - yield* closePartialText() - } else if (partialBlockType === 'thinking') { - yield* closePartialReasoning() + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: partialReasoningId, + role: 'reasoning' as const, + model, + timestamp: now(), } - partialBlockType = null - partialIsStructuredOutput = false - partialStructuredJson = '' + } + } + + function* handleContentBlockDelta( + event: Extract< + SdkPartialAssistantMessage['event'], + { type: 'content_block_delta' } + >, + ): Generator { + if ( + event.delta.type === 'text_delta' && + partialTextStarted && + partialTextMessageId && + typeof event.delta.text === 'string' + ) { + partialTextContent += event.delta.text + assistantTextForHarvest += event.delta.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: partialTextMessageId, + model, + timestamp: now(), + delta: event.delta.text, + content: partialTextContent, + } + return + } + if ( + event.delta.type === 'input_json_delta' && + partialIsStructuredOutput && + typeof event.delta.partial_json === 'string' + ) { + partialStructuredJson += event.delta.partial_json + return + } + if ( + event.delta.type === 'thinking_delta' && + partialReasoningId && + typeof event.delta.thinking === 'string' + ) { + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: partialReasoningId, + delta: event.delta.thinking, + model, + timestamp: now(), + } + } + } + + function* handleContentBlockStop(): Generator { + if (partialIsStructuredOutput && partialStructuredJson !== '') { + try { + capturedStructuredOutput = rememberStructuredOutput( + capturedStructuredOutput, + JSON.parse(partialStructuredJson), + ) + } catch { + // Incomplete JSON; the complete assistant tool_use may still arrive. + } + } + if (partialBlockType === 'text') { + yield* closePartialText() + } else if (partialBlockType === 'thinking') { + yield* closePartialReasoning() + } + partialBlockType = null + partialIsStructuredOutput = false + partialStructuredJson = '' + } + + function* handleStreamEvent( + message: SdkPartialAssistantMessage, + ): Generator { + const event = message.event + if (event.type === 'message_start') { + partialMessageId = event.message.id ?? genId() + streamedMessageIds.add(partialMessageId) + return + } + if (event.type === 'content_block_start') { + yield* handleContentBlockStart(event.content_block) + return + } + if (event.type === 'content_block_delta') { + yield* handleContentBlockDelta(event) + return + } + if (event.type === 'content_block_stop') { + yield* handleContentBlockStop() } } diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 1e8bd237f6..b79f86f652 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -330,6 +330,51 @@ const REJOIN_REBUILD_TRIGGERS = new Set([ 'MESSAGES_SNAPSHOT', ]) +function createClientToolsMap( + tools: ReadonlyArray | undefined, +): Map { + const map = new Map() + if (!tools) return map + for (const tool of tools) { + map.set(tool.name, tool) + } + return map +} + +function createChatClientCallbacks< + TTools extends ReadonlyArray, + TInterrupts extends ReadonlyArray>, +>(options: ChatClientOptions) { + return { + current: { + onResponse: options.onResponse || (() => {}), + onChunk: options.onChunk || (() => {}), + onFinish: options.onFinish || (() => {}), + onError: options.onError || (() => {}), + onMessagesChange: options.onMessagesChange || (() => {}), + onLoadingChange: options.onLoadingChange || (() => {}), + onErrorChange: options.onErrorChange || (() => {}), + onStatusChange: options.onStatusChange || (() => {}), + onSubscriptionChange: options.onSubscriptionChange || (() => {}), + onConnectionStatusChange: options.onConnectionStatusChange || (() => {}), + onSessionGeneratingChange: + options.onSessionGeneratingChange || (() => {}), + onQueueChange: options.onQueueChange || (() => {}), + onResumeStateChange: options.onResumeStateChange || (() => {}), + onRunIdChange: options.onRunIdChange || (() => {}), + onInterruptStateChange: options.onInterruptStateChange || (() => {}), + onCustomEvent: options.onCustomEvent || (() => {}), + }, + } +} + +function snapshotHasPendingInterrupts(snapshot: ChatResumeSnapshot): boolean { + return ( + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + ) +} + export class ChatClient< TTools extends ReadonlyArray = any, TContext = unknown, @@ -497,36 +542,9 @@ export class ChatClient< // `ensureThreadId()` from attach / mount / send. this.threadId = options.threadId || '' this.uniqueId = this.threadId - // `persistence` is `false`/omitted (ephemeral, in-memory), `true` - // (server-authoritative: cache nothing client-side, hydrate the thread from - // the server by `threadId` on mount), or a storage adapter - // (client-authoritative: cache the transcript plus resume pointer). Only the - // server-authoritative mode turns transcript caching off; that is what gates - // the mount hydration and keeps a client record from shadowing server history. - let cachesMessages = true - if (options.persistence === true) { - if (!options.threadId) { - throw new Error( - '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', - ) - } - cachesMessages = false - } else if (options.persistence) { - // A storage adapter: keep the combined record (transcript + resume pointer) - // in the browser. Persistence keys on `threadId` (the conversation - // identity) so a reload with the same `threadId` finds the same record. - if (!options.threadId) { - throw new Error( - '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', - ) - } - this.persistor = new ChatPersistor( - options.persistence, - options.threadId, - (messages) => this.processor.setMessages(messages), - (snapshot) => this.applyPersistedResume(snapshot), - ) - } + const persistence = this.createChatPersistor(options) + this.persistor = persistence.persistor + const cachesMessages = persistence.cachesMessages // Both `body` (deprecated) and `forwardedProps` populate the AG-UI // `RunAgentInput.forwardedProps` wire field. They are stored // separately so `updateOptions` can replace one without touching the @@ -542,41 +560,14 @@ export class ChatClient< this.connectionDrainsOnSend = connectionDrainsOnSend(transport) this.connection = normalizeConnectionAdapter(transport) - // Build client tools map - this.clientToolsRef = { current: new Map() } - if (options.tools) { - for (const tool of options.tools) { - this.clientToolsRef.current.set(tool.name, tool) - } - } + this.clientToolsRef = { current: createClientToolsMap(options.tools) } this.devtoolsBridge = ( options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge )(this.buildDevtoolsBridgeOptions(options.devtools)) this.events = this.devtoolsBridge.events - this.callbacksRef = { - current: { - onResponse: options.onResponse || (() => {}), - onChunk: options.onChunk || (() => {}), - onFinish: options.onFinish || (() => {}), - onError: options.onError || (() => {}), - onMessagesChange: options.onMessagesChange || (() => {}), - onLoadingChange: options.onLoadingChange || (() => {}), - onErrorChange: options.onErrorChange || (() => {}), - onStatusChange: options.onStatusChange || (() => {}), - onSubscriptionChange: options.onSubscriptionChange || (() => {}), - onConnectionStatusChange: - options.onConnectionStatusChange || (() => {}), - onSessionGeneratingChange: - options.onSessionGeneratingChange || (() => {}), - onQueueChange: options.onQueueChange || (() => {}), - onResumeStateChange: options.onResumeStateChange || (() => {}), - onRunIdChange: options.onRunIdChange || (() => {}), - onInterruptStateChange: options.onInterruptStateChange || (() => {}), - onCustomEvent: options.onCustomEvent || (() => {}), - }, - } + this.callbacksRef = createChatClientCallbacks(options) this.interruptManager = new InterruptManager({ ...(options.tools !== undefined ? { tools: options.tools } : {}), @@ -607,39 +598,10 @@ export class ChatClient< const initialMessages = syncPersistedState ? syncPersistedState.messages : options.initialMessages - // A durable snapshot read synchronously from storage wins over the - // in-memory `initialResumeSnapshot` fallback applied above. A snapshot with - // pending interrupts rehydrates the interrupt UI; a bare in-flight run is - // rejoined after the processor is ready (see `rejoinRunId` below). - let rejoinRunId: string | null = null - if (syncPersistedState?.resume) { - const snapshot = syncPersistedState.resume - const hasPendingInterrupts = - Array.isArray(snapshot.pendingInterrupts) && - snapshot.pendingInterrupts.length > 0 - if (hasPendingInterrupts) { - // Interrupts are run-scoped state, restored from the cached snapshot. - this.applyResumeSnapshot(snapshot) - } else if (snapshot.resumeState.runId) { - // A bare in-flight run pointer drives a client-authoritative rejoin. - rejoinRunId = snapshot.resumeState.runId - } - } - // A host-supplied `initialResumeSnapshot` carrying a bare in-flight run is - // rejoined too, not just its interrupts (which `applyResumeSnapshot` above - // already restored). This is how a server-authoritative app hands a FRESH - // client an in-flight run to tail — e.g. opening the thread on a second - // device / browser, where hydration reports the active run id but no local - // resume pointer exists. A run named by the persisted store wins. - if (!rejoinRunId && options.initialResumeSnapshot) { - const snapshot = options.initialResumeSnapshot - const hasPendingInterrupts = - Array.isArray(snapshot.pendingInterrupts) && - snapshot.pendingInterrupts.length > 0 - if (!hasPendingInterrupts && snapshot.resumeState.runId) { - rejoinRunId = snapshot.resumeState.runId - } - } + const rejoinRunId = this.resolveConstructorRejoinRunId( + options, + syncPersistedState, + ) this.processor = new StreamProcessor({ ...(options.streamProcessor?.chunkStrategy @@ -882,6 +844,73 @@ export class ChatClient< // `detach()` when it unmounts. Every framework wrapper in this repo does. } + private createChatPersistor( + options: ChatClientOptions, + ): { cachesMessages: boolean; persistor?: ChatPersistor } { + // `persistence` is `false`/omitted (ephemeral, in-memory), `true` + // (server-authoritative: cache nothing client-side, hydrate the thread from + // the server by `threadId` on mount), or a storage adapter + // (client-authoritative: cache the transcript plus resume pointer). Only the + // server-authoritative mode turns transcript caching off; that is what gates + // the mount hydration and keeps a client record from shadowing server history. + if (options.persistence === true) { + if (!options.threadId) { + throw new Error( + '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', + ) + } + return { cachesMessages: false } + } + if (!options.persistence) { + return { cachesMessages: true } + } + // A storage adapter: keep the combined record (transcript + resume pointer) + // in the browser. Persistence keys on `threadId` (the conversation + // identity) so a reload with the same `threadId` finds the same record. + if (!options.threadId) { + throw new Error( + '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', + ) + } + return { + cachesMessages: true, + persistor: new ChatPersistor( + options.persistence, + options.threadId, + (messages) => this.processor.setMessages(messages), + (snapshot) => this.applyPersistedResume(snapshot), + ), + } + } + + private resolveConstructorRejoinRunId( + options: ChatClientOptions, + syncPersistedState: { resume?: ChatResumeSnapshot } | undefined, + ): string | null { + if (syncPersistedState?.resume) { + const snapshot = syncPersistedState.resume + if (snapshotHasPendingInterrupts(snapshot)) { + // Interrupts are run-scoped state, restored from the cached snapshot. + this.applyResumeSnapshot(snapshot) + } else if (snapshot.resumeState.runId) { + // A bare in-flight run pointer drives a client-authoritative rejoin. + return snapshot.resumeState.runId + } + } + // A host-supplied `initialResumeSnapshot` carrying a bare in-flight run is + // rejoined too, not just its interrupts (which `applyResumeSnapshot` above + // already restored). This is how a server-authoritative app hands a FRESH + // client an in-flight run to tail — e.g. opening the thread on a second + // device / browser, where hydration reports the active run id but no local + // resume pointer exists. A run named by the persisted store wins. + if (!options.initialResumeSnapshot) return null + const snapshot = options.initialResumeSnapshot + if (!snapshotHasPendingInterrupts(snapshot) && snapshot.resumeState.runId) { + return snapshot.resumeState.runId + } + return null + } + /** * START TAILING: re-attach to an in-flight run so its chunks arrive here. * @@ -1187,76 +1216,82 @@ export class ChatClient< return } const runId = getChunkRunId(chunk) + if (this.hydrateInterruptedRun(chunk, runId)) { + return + } + if (this.shouldClearInterruptState(chunk, runId)) { + this.lastResume = null + // Run settled without an interrupt: drop the durable resume snapshot so a + // later reload does not try to rejoin a finished run. + this.persistor?.persistResumeSnapshot(null) + this.interruptManager.reset() + return + } + this.notifyResumeStateChange('live') + } + + private hydrateInterruptedRun( + chunk: StreamChunk, + runId: string | undefined, + ): boolean { + if (chunk.type !== 'RUN_FINISHED' || chunk.outcome?.type !== 'interrupt') { + return false + } const threadId = 'threadId' in chunk && typeof chunk.threadId === 'string' ? chunk.threadId : this.activeResumeThreadId - - if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') { - // Track the REQUEST run id (what the client sent) so a resume targets the - // same run even when provider events carry their own run id. - const interruptedRunId = - this.currentRunId ?? runId ?? this.activeResumeRunId ?? '' - this.lastResume = { - threadId: threadId ?? this.threadId, - runId: interruptedRunId, - } - this.interruptManager.hydrate( - { - threadId: this.lastResume.threadId, - interruptedRunId, - generation: this.interruptGeneration(chunk.outcome.interrupts), - interrupts: chunk.outcome.interrupts, - }, - 'live', - ) - return + // Track the REQUEST run id (what the client sent) so a resume targets the + // same run even when provider events carry their own run id. + const interruptedRunId = + this.currentRunId ?? runId ?? this.activeResumeRunId ?? '' + this.lastResume = { + threadId: threadId ?? this.threadId, + runId: interruptedRunId, } - - const isRunlessSessionError = chunk.type === 'RUN_ERROR' && !runId - const isTrackedRunTerminal = Boolean( - runId && this.lastResume?.runId === runId, - ) - const isCurrentRunTerminal = Boolean( - (runId && this.currentRunId === runId) || - (this.currentRunId && this.lastResume?.runId === this.currentRunId), + this.interruptManager.hydrate( + { + threadId: this.lastResume.threadId, + interruptedRunId, + generation: this.interruptGeneration(chunk.outcome.interrupts), + interrupts: chunk.outcome.interrupts, + }, + 'live', ) - // Provider adapters sometimes stamp a different run id on continuation - // events than the client-generated request id. RUN_STARTED updates - // `activeResumeRunId`, so match that too. - const isActiveStreamRunTerminal = Boolean( - this.isLoading && - runId && - (runId === this.activeResumeRunId || runId === this.currentRunId), + return true + } + + private isTrackedOrCurrentRunTerminal(runId: string | undefined): boolean { + if (runId && this.lastResume?.runId === runId) return true + if (runId && this.currentRunId === runId) return true + return Boolean( + this.currentRunId && this.lastResume?.runId === this.currentRunId, ) - const isCurrentStreamTerminal = - this.isLoading && chunk.type === 'RUN_FINISHED' && !runId + } + + private isActiveStreamRunTerminal(runId: string | undefined): boolean { + if (!this.isLoading || !runId) return false + return runId === this.activeResumeRunId || runId === this.currentRunId + } + + private shouldClearInterruptState( + chunk: StreamChunk, + runId: string | undefined, + ): boolean { + if (chunk.type === 'RUN_ERROR' && !runId) return true + if (this.isTrackedOrCurrentRunTerminal(runId)) return true + if (this.isActiveStreamRunTerminal(runId)) return true + if (this.isLoading && chunk.type === 'RUN_FINISHED' && !runId) return true // A resume batch that finishes successfully (or with a non-interrupt // terminal) must always clear pending interrupts — even when the provider // run id does not correlate. Otherwise Approve works once but the UI // keeps showing a stale prompt and blocks follow-up turns. - const isActiveInterruptSubmissionTerminal = Boolean( + return Boolean( this.activeInterruptSubmission && this.isLoading && chunk.type === 'RUN_FINISHED' && chunk.outcome?.type !== 'interrupt', ) - if ( - isRunlessSessionError || - isTrackedRunTerminal || - isCurrentRunTerminal || - isActiveStreamRunTerminal || - isCurrentStreamTerminal || - isActiveInterruptSubmissionTerminal - ) { - this.lastResume = null - // Run settled without an interrupt: drop the durable resume snapshot so a - // later reload does not try to rejoin a finished run. - this.persistor?.persistResumeSnapshot(null) - this.interruptManager.reset() - return - } - this.notifyResumeStateChange('live') } /** @@ -1745,80 +1780,105 @@ export class ChatClient< this.streamContinuationGeneration = this.continuationGeneration this.setIsLoading(true) this.setStatus('streaming') - void (async () => { - let rebuilt = false - let attached = false - // Whether the join FAILED (a thrown non-abort error before any chunk), as - // opposed to merely not delivering in time. Only a failure proves the - // pointer dead — see the `finally`. - let refused = false - const connectTimer = setTimeout(() => { - if (!attached) controller.abort() - }, REJOIN_CONNECT_DEADLINE_MS) - try { - for await (const chunk of joinRun(runId, controller.signal)) { - if (controller.signal.aborted) break - if (!attached) { - attached = true - clearTimeout(connectTimer) - } - if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { - rebuilt = true - this.dropTrailingInFlightAssistant() - } - await this.processIncomingChunk(chunk, { defer: false }) - } - // Same contract as `streamResponse`: client tools may finish (and - // queue a resume) while `isLoading` is still true. Wait for them - // before teardown so `drainPostStreamActions` below sees the queue. - if (this.pendingToolExecutions.size > 0) { - await Promise.all(this.pendingToolExecutions.values()) - } - } catch (error) { - // Pre-attach failures (unknown/evicted run, connect deadline abort) - // stay soft: keep the restored transcript. Post-attach transport/parser - // failures are real stream errors and must surface so the UI is not - // left truncated and silent. - const isAbort = - error instanceof Error && - (error.name === 'AbortError' || error.name === 'TimeoutError') - if (!attached && !isAbort) refused = true - if (attached && !isAbort) { - this.reportStreamError( - error instanceof Error ? error : new Error(String(error)), - ) - } - } finally { - clearTimeout(connectTimer) - if (!attached && refused && this.tailing && !this.disposed) { - // The server REFUSED the join (unknown / evicted run): the pointer is - // dead. Clear it so it does not retry and re-pin the UI on the next - // load. The server's persisted transcript is still loaded. - // - // A connect-deadline abort (or an external abort) deliberately does - // NOT clear it: the run may simply not have produced yet — a durable - // run whose middleware is still booting a sandbox emits nothing for - // a while — and clearing on a timeout would permanently orphan a run - // that is still going. The pointer survives for the next load, which - // costs that load one more bounded connect attempt. - // - // `tailing`/`disposed` guard the same pointer from the other side: a - // DETACH aborts before the first chunk exactly like an unreachable run - // does, and a refusal that lands after the view is gone belongs to - // nobody. `refused` already spares the timeout case; these two spare - // the "no view is watching any more" case, so the pointer only ever - // dies for a client that is still looking at the run. - this.lastResume = null - this.persistor?.persistResumeSnapshot(null) + void this.consumeRejoinStream(controller, runId, joinRun) + } + + private isRejoinAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) + } + + private handleRejoinFailure(error: unknown, attached: boolean): boolean { + // Pre-attach failures (unknown/evicted run, connect deadline abort) + // stay soft: keep the restored transcript. Post-attach transport/parser + // failures are real stream errors and must surface so the UI is not + // left truncated and silent. + const isAbort = this.isRejoinAbortError(error) + if (!attached && !isAbort) return true + if (attached && !isAbort) { + this.reportStreamError( + error instanceof Error ? error : new Error(String(error)), + ) + } + return false + } + + private clearDeadRejoinPointer(attached: boolean, refused: boolean): void { + if (attached || !refused || !this.tailing || this.disposed) return + // The server REFUSED the join (unknown / evicted run): the pointer is + // dead. Clear it so it does not retry and re-pin the UI on the next + // load. The server's persisted transcript is still loaded. + // + // A connect-deadline abort (or an external abort) deliberately does + // NOT clear it: the run may simply not have produced yet — a durable + // run whose middleware is still booting a sandbox emits nothing for + // a while — and clearing on a timeout would permanently orphan a run + // that is still going. The pointer survives for the next load, which + // costs that load one more bounded connect attempt. + // + // `tailing`/`disposed` guard the same pointer from the other side: a + // DETACH aborts before the first chunk exactly like an unreachable run + // does, and a refusal that lands after the view is gone belongs to + // nobody. `refused` already spares the timeout case; these two spare + // the "no view is watching any more" case, so the pointer only ever + // dies for a client that is still looking at the run. + this.lastResume = null + this.persistor?.persistResumeSnapshot(null) + } + + private async finishRejoin(controller: AbortController): Promise { + if (this.abortController !== controller) return + this.abortController = null + this.setIsLoading(false) + if (this.status === 'streaming') this.setStatus('ready') + await this.drainPostStreamActions() + } + + private async consumeRejoinStream( + controller: AbortController, + runId: string, + joinRun: ( + runId: string, + abortSignal?: AbortSignal, + ) => AsyncIterable, + ): Promise { + let rebuilt = false + let attached = false + // Whether the join FAILED (a thrown non-abort error before any chunk), as + // opposed to merely not delivering in time. Only a failure proves the + // pointer dead — see the `finally`. + let refused = false + const connectTimer = setTimeout(() => { + if (!attached) controller.abort() + }, REJOIN_CONNECT_DEADLINE_MS) + try { + for await (const chunk of joinRun(runId, controller.signal)) { + if (controller.signal.aborted) break + if (!attached) { + attached = true + clearTimeout(connectTimer) } - if (this.abortController === controller) { - this.abortController = null - this.setIsLoading(false) - if (this.status === 'streaming') this.setStatus('ready') - await this.drainPostStreamActions() + if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { + rebuilt = true + this.dropTrailingInFlightAssistant() } + await this.processIncomingChunk(chunk, { defer: false }) } - })() + // Same contract as `streamResponse`: client tools may finish (and + // queue a resume) while `isLoading` is still true. Wait for them + // before teardown so `drainPostStreamActions` below sees the queue. + if (this.pendingToolExecutions.size > 0) { + await Promise.all(this.pendingToolExecutions.values()) + } + } catch (error) { + refused = this.handleRejoinFailure(error, attached) + } finally { + clearTimeout(connectTimer) + this.clearDeadRejoinPointer(attached, refused) + await this.finishRejoin(controller) + } } /** @@ -2261,13 +2321,11 @@ export class ChatClient< let activeDevtoolsRunId: string | null = null let runTerminalEventEmitted = false - try { - // Get UIMessages with parts (preserves approval state and client tool results) + const executeStream = async (): Promise => { const messages = this.processor.getMessages() const clientTools = new Map(this.clientToolsRef.current) const runtimeContext = this.context - // Call onResponse callback await this.callbacksRef.current.onResponse() // If the stream was cancelled during the onResponse await (e.g. stop() @@ -2276,7 +2334,7 @@ export class ChatClient< // resolveProcessing() that ran during cancellation is a no-op and the // await processingComplete below would deadlock. if (signal.aborted) { - return false + return } // Merge sources for the wire `forwardedProps` field, in priority @@ -2294,35 +2352,16 @@ export class ChatClient< ...this.pendingMessageBody, } - // Clear the pending message body after use this.pendingMessageBody = undefined - - // Generate stream ID — assistant message will be created by stream events this.currentStreamId = this.generateUniqueId('stream') this.devtoolsBridge.setCurrentStreamId(this.currentStreamId) this.currentMessageId = null this.activeClientTools = clientTools this.activeContext = runtimeContext - - // Reset processor stream state for new response — prevents stale - // messageStates entries (from a previous stream) from blocking - // creation of a new assistant message (e.g. after reload). this.processor.prepareAssistantMessage() - - // Ensure subscription loop is running this.ensureSubscription() - - // Set up promise that resolves when onStreamEnd fires const processingComplete = this.waitForProcessing() - // Build per-send run context for AG-UI compliance - // Note: mergedBody already contains the merged this.body + pendingMessageBody - // (pendingMessageBody was cleared above, so we use mergedBody as forwardedProps) - // Convert each client tool's `inputSchema` (a Standard Schema: - // Zod, ArkType, Valibot, etc.) to JSON Schema for the wire. Foreign - // AG-UI servers consuming `RunAgentInput.tools[].parameters` expect - // JSON Schema; sending a Standard Schema instance directly would - // serialize to an unusable shape. let byokHeaders: Record | undefined if (this.byok) { const provider = resolveByokProviderId( @@ -2363,33 +2402,25 @@ export class ChatClient< ) this.devtoolsBridge.emitSnapshot() - // Send through normalized connection (pushes chunks to subscription queue) await this.connection.send(messages, mergedBody, signal, runContext) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated asynchronously during await if (generation !== this.streamGeneration || signal.aborted) { - return false + return } - // connect() send() already waited until the subscribe queue was idle. - // Kick the processing wait so a stream that ends on tool_calls (no - // interrupt / stop) cannot hang. Subscribe/send sockets still wait for - // a request-ending terminal below. + // connect() send() already drained the subscribe queue. Kick processing + // so a tool_calls end cannot hang. Sockets still wait for a terminal. if (this.connectionDrainsOnSend) { this.resolveProcessing() } - // Wait for subscription loop to finish processing all chunks await processingComplete - // If this stream was superseded (e.g. by reload()), bail out — - // the new stream owns the processor and processingResolve now. if (generation !== this.streamGeneration) { - return false + return } - // A RUN_ERROR from the stream transitions status to error. - // Do not treat this stream as a successful completion. if (this.status === 'error') { if (activeDevtoolsRunId) { this.devtoolsBridge.emitRunLifecycle( @@ -2400,18 +2431,18 @@ export class ChatClient< ) runTerminalEventEmitted = true } - return false + return } - // Wait for pending client tool executions if (this.pendingToolExecutions.size > 0) { await Promise.all(this.pendingToolExecutions.values()) } - // Finalize (idempotent — may already be done by RUN_FINISHED handler) this.processor.finalizeStream() streamCompletedSuccessfully = true - } catch (err: unknown) { + } + + const handleStreamFailure = (err: unknown): void => { const error = err instanceof Error ? err : new Error(String(err)) if (error.name === 'AbortError') { if (activeDevtoolsRunId) { @@ -2422,7 +2453,7 @@ export class ChatClient< ) runTerminalEventEmitted = true } - return false + return } if (error instanceof ByokMissingError) { this.byok?.request(error.provider, 'missing') @@ -2450,61 +2481,67 @@ export class ChatClient< ) { throw error } - } finally { + } + + const finishStream = async (): Promise => { // Only clean up if this is still the active stream. // A superseded stream (e.g. reload() started a new one) must not // clobber the new stream's abortController or isLoading state. - if (generation === this.streamGeneration) { - this.currentStreamId = null - this.devtoolsBridge.setCurrentStreamId(null) - this.currentMessageId = null - this.setCurrentRunId(null) - this.activeClientTools = null - this.activeContext = undefined - this.abortController = null - this.setIsLoading(false) - this.pendingMessageBody = undefined // Ensure it's cleared even on error - - if (activeDevtoolsRunId && !runTerminalEventEmitted) { - if (streamCompletedSuccessfully) { - this.devtoolsBridge.emitRunLifecycle( - 'run:completed', - activeDevtoolsRunId, - 'completed', - ) - } else if (signal.aborted) { - this.devtoolsBridge.emitRunLifecycle( - 'run:cancelled', - activeDevtoolsRunId, - 'cancelled', - ) - } - } - - // Drain any actions that were queued while the stream was in progress - await this.drainPostStreamActions() + if (generation !== this.streamGeneration) return + this.currentStreamId = null + this.devtoolsBridge.setCurrentStreamId(null) + this.currentMessageId = null + this.setCurrentRunId(null) + this.activeClientTools = null + this.activeContext = undefined + this.abortController = null + this.setIsLoading(false) + this.pendingMessageBody = undefined + if (activeDevtoolsRunId && !runTerminalEventEmitted) { if (streamCompletedSuccessfully) { - if (this.status !== 'ready') { - // Terminal run, but onStreamEnd never fired: the processor had - // no assistant message to emit it for (e.g. a bare - // RUN_FINISHED{stop}, #421). The normal path already set - // 'ready', so this is a no-op. - this.setStatus('ready') - } - // Auto-send queued messages once the run fully settles. Skip if a - // drain loop is already walking the queue (avoids nested re-entry). - if (!this.messageQueueDraining) { - await this.drainQueue() - } - } else { - // Error/abort settle for the active generation: don't strand or - // later mis-order queued messages. A failed turn flushes the queue - // (consistent with stop()); it must NOT auto-drain into a likely - // broken endpoint. - this.flushQueue() + this.devtoolsBridge.emitRunLifecycle( + 'run:completed', + activeDevtoolsRunId, + 'completed', + ) + } else if (signal.aborted) { + this.devtoolsBridge.emitRunLifecycle( + 'run:cancelled', + activeDevtoolsRunId, + 'cancelled', + ) } } + + await this.drainPostStreamActions() + + if (!streamCompletedSuccessfully) { + // Error/abort settle for the active generation: don't strand or + // later mis-order queued messages. A failed turn flushes the queue + // (consistent with stop()); it must NOT auto-drain into a likely + // broken endpoint. + this.flushQueue() + return + } + if (this.status !== 'ready') { + // Terminal run, but onStreamEnd never fired: the processor had + // no assistant message to emit it for (e.g. a bare + // RUN_FINISHED{stop}, #421). The normal path already set + // 'ready', so this is a no-op. + this.setStatus('ready') + } + if (!this.messageQueueDraining) { + await this.drainQueue() + } + } + + try { + await executeStream() + } catch (err: unknown) { + handleStreamFailure(err) + } finally { + await finishStream() } return streamCompletedSuccessfully @@ -3065,32 +3102,50 @@ export class ChatClient< context?: TContext | undefined }, ): void { - if (options.connection !== undefined || options.fetcher !== undefined) { - const wasSubscribed = this.isSubscribed + this.applyConnectionUpdate(options) + this.applyClientOptionSlots(options) + this.applyCallbackUpdates(options) + } - if (this.isLoading) { - this.cancelInFlightStream({ - setReadyStatus: true, - abortSubscription: true, - }) - } else if (wasSubscribed) { - this.abortSubscriptionLoop() - } + private applyConnectionUpdate( + options: ChatClientUpdateOptionsWithoutContext & { + context?: TContext | undefined + }, + ): void { + if (options.connection === undefined && options.fetcher === undefined) { + return + } + const wasSubscribed = this.isSubscribed - this.resetSessionGenerating() - this.setIsSubscribed(false) - this.setConnectionStatus('disconnected') - const transport = resolveTransport({ - connection: options.connection, - fetcher: options.fetcher, + if (this.isLoading) { + this.cancelInFlightStream({ + setReadyStatus: true, + abortSubscription: true, }) - this.connectionDrainsOnSend = connectionDrainsOnSend(transport) - this.connection = normalizeConnectionAdapter(transport) + } else if (wasSubscribed) { + this.abortSubscriptionLoop() + } - if (wasSubscribed) { - this.subscribe() - } + this.resetSessionGenerating() + this.setIsSubscribed(false) + this.setConnectionStatus('disconnected') + const transport = resolveTransport({ + connection: options.connection, + fetcher: options.fetcher, + }) + this.connectionDrainsOnSend = connectionDrainsOnSend(transport) + this.connection = normalizeConnectionAdapter(transport) + + if (wasSubscribed) { + this.subscribe() } + } + + private applyClientOptionSlots( + options: ChatClientUpdateOptionsWithoutContext & { + context?: TContext | undefined + }, + ): void { // Replace each wire-payload slot independently so callers can update one // without wiping the other. Passing `undefined` for `body` or // `forwardedProps` leaves that slot unchanged; context is cleared when the @@ -3112,15 +3167,19 @@ export class ChatClient< } if (options.tools !== undefined) { this.interruptManager.updateTools(options.tools) - this.clientToolsRef.current = new Map() - for (const tool of options.tools) { - this.clientToolsRef.current.set(tool.name, tool) - } + this.clientToolsRef.current = createClientToolsMap(options.tools) this.devtoolsBridge.notifyToolsChanged() } if (options.queue !== undefined) { this.queueConfig = normalizeQueueOption(options.queue) } + } + + private applyCallbackUpdates( + options: ChatClientUpdateOptionsWithoutContext & { + context?: TContext | undefined + }, + ): void { if (options.onResponse !== undefined) { this.callbacksRef.current.onResponse = options.onResponse } diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 6f6c30a658..ea8dbc90b7 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -418,22 +418,72 @@ function sseChunkModel(chunk: StreamChunk): string | undefined { * * A JSON parse failure throws — the consumer surfaces it as an error. */ +interface SseEventParseState { + lastThreadId?: string + lastRunId?: string + lastModel?: string + pendingId?: string +} + +function readSseIdLine(line: string): string | false { + if (line !== 'id' && !line.startsWith('id:')) return false + // SSE spec: strip a single leading space after the colon, preserve the + // rest verbatim so an opaque adapter offset round-trips exactly (do NOT + // trim, which would mangle a legitimate offset). An empty value is kept as + // '' and resets the resume cursor downstream (see resumableStream). + const rawId = line === 'id' ? '' : line.slice(3) + return rawId.startsWith(' ') ? rawId.slice(1) : rawId +} + +function isSseControlLine(line: string): boolean { + return ( + line.startsWith(':') || + line.startsWith('event:') || + line.startsWith('retry:') + ) +} + +function createDoneStreamChunk( + state: SseEventParseState, + fallbackIds?: { threadId?: string; runId?: string }, +): StreamChunk { + return withTanstackMetadata( + { + type: EventType.RUN_FINISHED, + threadId: state.lastThreadId ?? fallbackIds?.threadId ?? '', + runId: state.lastRunId ?? fallbackIds?.runId ?? '', + timestamp: Date.now(), + }, + { + finishReason: 'stop', + ...(state.lastModel !== undefined ? { model: state.lastModel } : {}), + }, + ) as StreamChunk +} + +function recordSseChunkIds( + chunk: StreamChunk, + state: SseEventParseState, +): void { + if ('threadId' in chunk && typeof chunk.threadId === 'string') { + state.lastThreadId = chunk.threadId + } + if ('runId' in chunk && typeof chunk.runId === 'string') { + state.lastRunId = chunk.runId + } + const model = sseChunkModel(chunk) + if (model !== undefined) state.lastModel = model +} + async function* linesToSSEEvents( lines: AsyncIterable, fallbackIds?: { threadId?: string; runId?: string }, ): AsyncGenerator { - let lastThreadId: string | undefined - let lastRunId: string | undefined - let lastModel: string | undefined - let pendingId: string | undefined + const state: SseEventParseState = {} for await (const line of lines) { - if (line === 'id' || line.startsWith('id:')) { - // SSE spec: strip a single leading space after the colon, preserve the - // rest verbatim so an opaque adapter offset round-trips exactly (do NOT - // trim, which would mangle a legitimate offset). An empty value is kept as - // '' and resets the resume cursor downstream (see resumableStream). - const rawId = line === 'id' ? '' : line.slice(3) - pendingId = rawId.startsWith(' ') ? rawId.slice(1) : rawId + const idValue = readSseIdLine(line) + if (idValue !== false) { + state.pendingId = idValue continue } // Assumes the durability wire emits one `id:` immediately followed by one @@ -441,42 +491,18 @@ async function* linesToSSEEvents( // next data line and is cleared after it; blank-line event boundaries are // stripped upstream, so a hand-rolled server that emits an id-only event or // a persistent `id:` across events is not supported here. - if ( - line.startsWith(':') || - line.startsWith('event:') || - line.startsWith('retry:') - ) { + if (isSseControlLine(line)) { continue } const data = parseSseDataLine(line) if (data === '[DONE]') { - yield { - chunk: withTanstackMetadata( - { - type: EventType.RUN_FINISHED, - threadId: lastThreadId ?? fallbackIds?.threadId ?? '', - runId: lastRunId ?? fallbackIds?.runId ?? '', - timestamp: Date.now(), - }, - { - finishReason: 'stop', - ...(lastModel !== undefined ? { model: lastModel } : {}), - }, - ) as StreamChunk, - } + yield { chunk: createDoneStreamChunk(state, fallbackIds) } return } const chunk = restoreInboundUsage(JSON.parse(data) as StreamChunk) - if ('threadId' in chunk && typeof chunk.threadId === 'string') { - lastThreadId = chunk.threadId - } - if ('runId' in chunk && typeof chunk.runId === 'string') { - lastRunId = chunk.runId - } - const model = sseChunkModel(chunk) - if (model !== undefined) lastModel = model - const id = pendingId - pendingId = undefined + recordSseChunkIds(chunk, state) + const id = state.pendingId + state.pendingId = undefined yield { chunk, ...(id !== undefined ? { id } : {}) } } } @@ -1001,6 +1027,85 @@ export type ConnectionAdapter = * If a connection provides native subscribe/send, that mode is used. * Otherwise, connect() is wrapped using an async queue. */ +interface ConnectSendState { + hasTerminalEvent: boolean + upstreamThreadId?: string + upstreamRunId?: string +} + +function noteConnectChunk(chunk: StreamChunk, state: ConnectSendState): void { + if ('threadId' in chunk && typeof chunk.threadId === 'string') { + state.upstreamThreadId = chunk.threadId + } + if ('runId' in chunk && typeof chunk.runId === 'string') { + state.upstreamRunId = chunk.runId + } + if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + state.hasTerminalEvent = true + } +} + +function pushSyntheticRunFinished( + state: ConnectSendState, + abortSignal: AbortSignal | undefined, + runContext: RunAgentInputContext | undefined, + push: (chunk: StreamChunk, runId?: string) => void, +): void { + if (abortSignal?.aborted || state.hasTerminalEvent) return + push( + withTanstackMetadata( + { + type: EventType.RUN_FINISHED, + threadId: requireSyntheticId( + state.upstreamThreadId ?? runContext?.threadId, + 'threadId', + ), + runId: requireSyntheticId( + state.upstreamRunId ?? runContext?.runId, + 'runId', + ), + timestamp: Date.now(), + }, + { finishReason: 'stop', model: 'connect-wrapper' }, + ) as StreamChunk, + runContext?.runId, + ) +} + +function pushSyntheticRunError( + state: ConnectSendState, + abortSignal: AbortSignal | undefined, + runContext: RunAgentInputContext | undefined, + err: unknown, + push: (chunk: StreamChunk, runId?: string) => void, +): void { + if (abortSignal?.aborted || state.hasTerminalEvent) return + // Guard synthesis: requireSyntheticId throws when no id is available, + // and that must not replace the original `err` we are about to + // rethrow. If we can't synthesize a terminal, the real failure still + // surfaces below. + try { + const message = + err instanceof Error ? err.message : 'Unknown error in connect()' + const synthetic: RunErrorEvent = { + type: EventType.RUN_ERROR, + threadId: requireSyntheticId( + state.upstreamThreadId ?? runContext?.threadId, + 'threadId', + ), + runId: requireSyntheticId( + state.upstreamRunId ?? runContext?.runId, + 'runId', + ), + timestamp: Date.now(), + message, + } + push(synthetic, runContext?.runId) + } catch { + // fall through to rethrow the original error + } +} + export function normalizeConnectionAdapter( connection: ConnectionAdapter | undefined, ): SubscribeConnectionAdapter { @@ -1107,9 +1212,7 @@ export function normalizeConnectionAdapter( })() }, async send(messages, data, abortSignal, runContext) { - let hasTerminalEvent = false - let upstreamThreadId: string | undefined - let upstreamRunId: string | undefined + const state: ConnectSendState = { hasTerminalEvent: false } try { const stream = connection.connect( messages, @@ -1118,15 +1221,7 @@ export function normalizeConnectionAdapter( runContext, ) for await (const chunk of stream) { - if ('threadId' in chunk && typeof chunk.threadId === 'string') { - upstreamThreadId = chunk.threadId - } - if ('runId' in chunk && typeof chunk.runId === 'string') { - upstreamRunId = chunk.runId - } - if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - hasTerminalEvent = true - } + noteConnectChunk(chunk, state) push(chunk, runContext?.runId) } @@ -1135,53 +1230,9 @@ export function normalizeConnectionAdapter( // The event payload may carry an upstream/provider runId when one was // observed, but stamp the caller's request runId so getChunkRunId() // correlates to activeRunIds / currentRunId (same as real stream chunks). - if (!abortSignal?.aborted && !hasTerminalEvent) { - push( - withTanstackMetadata( - { - type: EventType.RUN_FINISHED, - threadId: requireSyntheticId( - upstreamThreadId ?? runContext?.threadId, - 'threadId', - ), - runId: requireSyntheticId( - upstreamRunId ?? runContext?.runId, - 'runId', - ), - timestamp: Date.now(), - }, - { finishReason: 'stop', model: 'connect-wrapper' }, - ) as StreamChunk, - runContext?.runId, - ) - } + pushSyntheticRunFinished(state, abortSignal, runContext, push) } catch (err) { - if (!abortSignal?.aborted && !hasTerminalEvent) { - // Guard synthesis: requireSyntheticId throws when no id is available, - // and that must not replace the original `err` we are about to - // rethrow. If we can't synthesize a terminal, the real failure still - // surfaces below. - try { - const message = - err instanceof Error ? err.message : 'Unknown error in connect()' - const synthetic: RunErrorEvent = { - type: EventType.RUN_ERROR, - threadId: requireSyntheticId( - upstreamThreadId ?? runContext?.threadId, - 'threadId', - ), - runId: requireSyntheticId( - upstreamRunId ?? runContext?.runId, - 'runId', - ), - timestamp: Date.now(), - message, - } - push(synthetic, runContext?.runId) - } catch { - // fall through to rethrow the original error - } - } + pushSyntheticRunError(state, abortSignal, runContext, err, push) throw err } await waitUntilSubscriberIdle(abortSignal) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index b61634bd9b..d0e80669cf 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -275,81 +275,9 @@ export class GenerationClient< const { signal } = abortController try { - let headers: Record | undefined - if (this.byok) { - const provider = resolveByokProviderId( - this.byokProvider, - this.body.provider, - ) - headers = await prepareResolvedByokHeaders(this.byok, provider) - } - - if (this.fetcher) { - // Direct fetch path - const result = await this.fetcher( - input, - headers === undefined ? { signal } : { signal, headers }, - ) - if (signal.aborted) return - if (result instanceof Response) { - // Server function returned SSE Response — parse stream - await this.processStream( - parseSSEResponse(result, signal), - runId, - signal, - ) - } else { - this.devtoolsBridge.ensureRunStarted(runId) - this.setResult(result) - this.setStatus('success') - this.completePlainFetcherResumeSnapshot(result) - } - } else if (this.connection) { - // Streaming adapter path - const mergedData = { ...this.body, ...input } - const stream = this.connection.connect( - [], - mergedData, - signal, - this.createRunContext(runId, headers), - ) - await this.processStream(stream, runId, signal) - } else { - throw new Error( - 'GenerationClient requires either a connection or fetcher option', - ) - } - if (!signal.aborted && this.status === 'success') { - // Bump progress to 100 on successful completion so devtools - // snapshots reflect the final state. The bridge mirrors this in - // the run's recorded progress, but the snapshot reads `progress` - // from the client's core state. - this.progress = completeProgressValue(this.progress) - this.devtoolsBridge.finishRun( - this.devtoolsBridge.getActiveRunId() ?? runId, - 'run:completed', - 'completed', - ) - } + await this.executeGenerate(input, runId, signal) } catch (err: unknown) { - if (signal.aborted) return - const error = err instanceof Error ? err : new Error(String(err)) - if (error instanceof ByokMissingError) { - this.byok?.request(error.provider, 'missing') - } - if (error instanceof ByokBlockedError && error.reason === 'locked') { - this.byok?.request(error.provider, 'locked') - } - this.setError(error) - this.setStatus('error') - this.recordResumeSnapshotError(error) - this.devtoolsBridge.finishRun( - this.devtoolsBridge.getActiveRunId() ?? runId, - 'run:errored', - 'errored', - error.message, - ) - this.callbacksRef.onError?.(error) + this.handleGenerateFailure(err, signal, runId) } finally { if (this.abortController === abortController) { this.abortController = null @@ -358,6 +286,97 @@ export class GenerationClient< } } + private async executeGenerate( + input: TInput, + runId: string, + signal: AbortSignal, + ): Promise { + let headers: Record | undefined + if (this.byok) { + const provider = resolveByokProviderId( + this.byokProvider, + this.body.provider, + ) + headers = await prepareResolvedByokHeaders(this.byok, provider) + } + + if (this.fetcher) { + await this.generateWithFetcher(input, signal, runId, headers) + } else if (this.connection) { + const mergedData = { ...this.body, ...input } + const stream = this.connection.connect( + [], + mergedData, + signal, + this.createRunContext(runId, headers), + ) + await this.processStream(stream, runId, signal) + } else { + throw new Error( + 'GenerationClient requires either a connection or fetcher option', + ) + } + if (!signal.aborted && this.status === 'success') { + // Bump progress to 100 on successful completion so devtools + // snapshots reflect the final state. The bridge mirrors this in + // the run's recorded progress, but the snapshot reads `progress` + // from the client's core state. + this.progress = completeProgressValue(this.progress) + this.devtoolsBridge.finishRun( + this.devtoolsBridge.getActiveRunId() ?? runId, + 'run:completed', + 'completed', + ) + } + } + + private async generateWithFetcher( + input: TInput, + signal: AbortSignal, + runId: string, + headers?: Record, + ): Promise { + if (!this.fetcher) return + const result = await this.fetcher( + input, + headers === undefined ? { signal } : { signal, headers }, + ) + if (signal.aborted) return + if (result instanceof Response) { + await this.processStream(parseSSEResponse(result, signal), runId, signal) + return + } + this.devtoolsBridge.ensureRunStarted(runId) + this.setResult(result) + this.setStatus('success') + this.completePlainFetcherResumeSnapshot(result) + } + + private handleGenerateFailure( + err: unknown, + signal: AbortSignal, + runId: string, + ): void { + if (signal.aborted) return + const error = err instanceof Error ? err : new Error(String(err)) + if (error instanceof ByokMissingError) { + this.byok?.request(error.provider, 'missing') + } + if (error instanceof ByokBlockedError && error.reason === 'locked') { + this.byok?.request(error.provider, 'locked') + } + this.setError(error) + this.setStatus('error') + this.recordResumeSnapshotError(error) + this.devtoolsBridge.finishRun( + this.devtoolsBridge.getActiveRunId() ?? runId, + 'run:errored', + 'errored', + error.message, + ) + this.callbacksRef.onError?.(error) + } + /** * Process a stream of AG-UI events from the streaming connection adapter. * diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index bee7670bb0..4a9f0a6b50 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -465,29 +465,24 @@ export interface GenerationRestoredResult { artifacts: Array } -/** - * Reduces one observed stream chunk into the lightweight resume snapshot. - * - * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` / - * `pendingArtifacts` from a previous run are dropped rather than carried into - * the new run's snapshot. - * - * @internal - */ -export function updateGenerationResumeSnapshot( +function chunkCorrelationId( + chunk: StreamChunk, + key: 'threadId' | 'runId', +): string | undefined { + const tanstack = tanstackMetadata(chunk) + const fromChunk = stringField(chunk, key) + if (fromChunk) return fromChunk + const fromMeta = tanstack?.[key] + return typeof fromMeta === 'string' ? fromMeta : undefined +} + +function createCarriedResumeSnapshot( previous: GenerationResumeSnapshot | null | undefined, chunk: StreamChunk, ): GenerationResumeSnapshot { - const tanstack = tanstackMetadata(chunk) - const threadId = - stringField(chunk, 'threadId') ?? - (typeof tanstack?.threadId === 'string' ? tanstack.threadId : undefined) - const runId = - stringField(chunk, 'runId') ?? - (typeof tanstack?.runId === 'string' ? tanstack.runId : undefined) const carried = chunk.type === 'RUN_STARTED' ? undefined : previous const previousArtifacts = carried?.pendingArtifacts ?? [] - const next: GenerationResumeSnapshot = { + return { schemaVersion: 1, resumeState: carried?.resumeState ?? null, status: carried?.status ?? 'idle', @@ -499,50 +494,112 @@ export function updateGenerationResumeSnapshot( ...(carried?.error ? { error: { ...carried.error } } : {}), lastEvent: createGenerationEventSnapshot(chunk), } +} +function applyResumeIdentity( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + const threadId = chunkCorrelationId(chunk, 'threadId') + const runId = chunkCorrelationId(chunk, 'runId') if (threadId && runId) { next.resumeState = { threadId, runId } next.status = 'running' - } else if (chunk.type === 'RUN_STARTED') { + return + } + if (chunk.type === 'RUN_STARTED') { next.status = 'running' } +} - if (chunk.type === 'CUSTOM') { - if (chunk.name === GENERATION_EVENTS.ARTIFACTS) { - const artifacts = collectArtifactRefs(chunk.value) - if (artifacts.length > 0) { - next.pendingArtifacts = artifacts - next.activity = artifacts[0]?.source.activity - } - } else if (chunk.name === GENERATION_EVENTS.RESULT) { - const result = createGenerationResultSnapshot(chunk.value) - if (result) { - next.result = result - if (result.artifacts && result.artifacts.length > 0) { - next.pendingArtifacts = result.artifacts - next.activity = result.artifacts[0]?.source.activity - } - } - } else if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { - // Capture the provider job id as soon as the job exists — for a long - // video run this is the one piece of identity worth having after a - // reload, and the terminal `generation:result` may never arrive. - const providerJobId = isObject(chunk.value) - ? stringField(chunk.value, 'jobId') - : undefined - if (providerJobId) { - next.result = { ...next.result, providerJobId } - } - } - } else if (chunk.type === 'RUN_FINISHED') { +function applyArtifactsCustomEvent( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + const artifacts = collectArtifactRefs(chunk.value) + if (artifacts.length === 0) return + next.pendingArtifacts = artifacts + next.activity = artifacts[0]?.source.activity +} + +function applyResultCustomEvent( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + const result = createGenerationResultSnapshot(chunk.value) + if (!result) return + next.result = result + if (result.artifacts && result.artifacts.length > 0) { + next.pendingArtifacts = result.artifacts + next.activity = result.artifacts[0]?.source.activity + } +} + +function applyVideoJobCreatedCustomEvent( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + // Capture the provider job id as soon as the job exists — for a long + // video run this is the one piece of identity worth having after a + // reload, and the terminal `generation:result` may never arrive. + const providerJobId = isObject(chunk.value) + ? stringField(chunk.value, 'jobId') + : undefined + if (providerJobId) { + next.result = { ...next.result, providerJobId } + } +} + +const generationCustomEventHandlers: Record< + string, + (next: GenerationResumeSnapshot, chunk: StreamChunk) => void +> = { + [GENERATION_EVENTS.ARTIFACTS]: applyArtifactsCustomEvent, + [GENERATION_EVENTS.RESULT]: applyResultCustomEvent, + [GENERATION_EVENTS.VIDEO_JOB_CREATED]: applyVideoJobCreatedCustomEvent, +} + +function applyGenerationCustomEvent( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + if (chunk.type !== 'CUSTOM') return + generationCustomEventHandlers[chunk.name]?.(next, chunk) +} + +function applyGenerationRunTerminal( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + if (chunk.type === 'RUN_FINISHED') { next.resumeState = null next.status = 'complete' - } else if (chunk.type === 'RUN_ERROR') { + return + } + if (chunk.type === 'RUN_ERROR') { next.resumeState = null next.status = 'error' next.error = createGenerationErrorSnapshot(chunk) } +} +/** + * Reduces one observed stream chunk into the lightweight resume snapshot. + * + * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` / + * `pendingArtifacts` from a previous run are dropped rather than carried into + * the new run's snapshot. + * + * @internal + */ +export function updateGenerationResumeSnapshot( + previous: GenerationResumeSnapshot | null | undefined, + chunk: StreamChunk, +): GenerationResumeSnapshot { + const next = createCarriedResumeSnapshot(previous, chunk) + applyResumeIdentity(next, chunk) + applyGenerationCustomEvent(next, chunk) + applyGenerationRunTerminal(next, chunk) return next } diff --git a/packages/ai-client/src/interrupt-manager.ts b/packages/ai-client/src/interrupt-manager.ts index 44225bd387..a4253edd20 100644 --- a/packages/ai-client/src/interrupt-manager.ts +++ b/packages/ai-client/src/interrupt-manager.ts @@ -460,6 +460,53 @@ function genericBinding( }) } +function isCorrelatedBinding( + candidate: InterruptBinding, + interrupt: Interrupt, + hydration: InterruptManagerHydration, +): boolean { + return ( + candidate.interruptId === interrupt.id && + candidate.interruptedRunId === hydration.interruptedRunId && + candidate.generation === hydration.generation && + responseSchemaHash(interrupt) === candidate.responseSchemaHash + ) +} + +function isStructurallyCorrelatedBinding( + candidate: InterruptBinding, + interrupt: Interrupt, + hydration: InterruptManagerHydration, +): boolean { + return ( + candidate.interruptId === interrupt.id && + candidate.interruptedRunId === hydration.interruptedRunId && + candidate.generation === hydration.generation && + candidate.responseSchemaHash === + (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash) + ) +} + +function isGenericFallbackResumable( + legacyResumable: boolean, + candidate: InterruptBinding | undefined, + interrupt: Interrupt, + hydration: InterruptManagerHydration, +): boolean { + if (legacyResumable) return true + if (candidate === undefined) return false + if ( + candidate.interruptId !== interrupt.id || + candidate.interruptedRunId !== hydration.interruptedRunId || + candidate.generation !== hydration.generation + ) { + return false + } + if (candidate.kind !== 'generic') return true + const hash = responseSchemaHash(interrupt) + return hash === undefined || candidate.responseSchemaHash === hash +} + function baseSnapshot( item: RuntimeInterrupt, hydration: InterruptManagerHydration, @@ -723,6 +770,65 @@ export class InterruptManager< const legacyResumable = candidate === undefined && isLegacyInterruptMetadata(interrupt) + if (candidate === undefined && !legacyResumable) { + return this.hydrateUnownedInterrupt(interrupt, hydration) + } + + const structurallyCorrelated = + candidate !== undefined && + isStructurallyCorrelatedBinding(candidate, interrupt, hydration) + const mismatched = this.hydrateMismatchedFirstPartyGeneric( + interrupt, + hydration, + candidate, + structurallyCorrelated, + ) + if (mismatched) return mismatched + + const toolApproval = this.hydrateToolApprovalInterrupt( + interrupt, + candidate, + structurallyCorrelated, + ) + if (toolApproval) return toolApproval + + const clientTool = this.hydrateClientToolInterrupt( + interrupt, + candidate, + structurallyCorrelated, + ) + if (clientTool) return clientTool + + const duplicate = this.hydrateDuplicateGenericBatch( + interrupt, + candidate, + firstPartyIndexes, + ) + if (duplicate) return duplicate + + const correlated = + candidate !== undefined && + isCorrelatedBinding(candidate, interrupt, hydration) + const firstParty = this.hydrateFirstPartyGenericInterrupt( + interrupt, + candidate, + correlated, + firstPartyIndexes, + ) + if (firstParty) return firstParty + + return this.hydrateGenericFallbackInterrupt( + interrupt, + hydration, + candidate, + legacyResumable, + ) + } + + private hydrateUnownedInterrupt( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + ): RuntimeInterrupt { // No binding we understand, and nothing else identifying the descriptor as // ours, means this interrupt was not produced by this package's resume // path — a workflow engine's durable approval projected onto the same @@ -739,61 +845,10 @@ export class InterruptManager< // Pre-binding TanStack descriptors are still ours: they carry the legacy // `metadata.kind` marker, so they keep hydrating through the generic path // below. - if (candidate === undefined && !legacyResumable) { - if (hasReservedFirstPartyBindingMarker(interrupt)) { - return { - descriptor: interrupt, - binding: genericBinding(interrupt, hydration, undefined), - kind: 'generic', - status: 'error', - canResolve: false, - resumable: false, - error: this.itemError( - interrupt.id, - 'stale', - 'The interrupt binding is invalid or incomplete.', - ), - validationGeneration: 0, - } - } - return { - descriptor: interrupt, - binding: undefined, - kind: 'unbound', - status: 'pending', - canResolve: false, - resumable: false, - validationGeneration: 0, - } - } - - const correlated = - candidate !== undefined && - candidate.interruptId === interrupt.id && - candidate.interruptedRunId === hydration.interruptedRunId && - candidate.generation === hydration.generation && - responseSchemaHash(interrupt) === candidate.responseSchemaHash - - const structurallyCorrelated = - candidate !== undefined && - candidate.interruptId === interrupt.id && - candidate.interruptedRunId === hydration.interruptedRunId && - candidate.generation === hydration.generation && - candidate.responseSchemaHash === - (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash) - - if ( - candidate !== undefined && - hasFirstPartyGenericMarker(interrupt) && - (!structurallyCorrelated || - candidate.kind !== 'generic' || - candidate.definitionId === undefined || - candidate.key === undefined || - candidate.batchIndex === undefined) - ) { + if (hasReservedFirstPartyBindingMarker(interrupt)) { return { descriptor: interrupt, - binding: genericBinding(interrupt, hydration, candidate), + binding: genericBinding(interrupt, hydration, undefined), kind: 'generic', status: 'error', canResolve: false, @@ -801,64 +856,93 @@ export class InterruptManager< error: this.itemError( interrupt.id, 'stale', - 'The interrupt binding does not match this interrupted run.', + 'The interrupt binding is invalid or incomplete.', ), validationGeneration: 0, } } + return { + descriptor: interrupt, + binding: undefined, + kind: 'unbound', + status: 'pending', + canResolve: false, + resumable: false, + validationGeneration: 0, + } + } - if (structurallyCorrelated && candidate.kind === 'tool-approval') { - const tool = this.tools?.find( - (configured) => configured.name === candidate.toolName, - ) - // Gated on the binding and the schema hashes below, not on - // `interrupt.reason` — that string is free-form AG-UI text another - // producer can also use, so it cannot be what decides ownership. - if ( - tool?.needsApproval === true && - interrupt.toolCallId === candidate.toolCallId - ) { - try { - const approval = normalizeApprovalSchema( - tool.approvalSchema, - tool.inputSchema, - ) - if ( - hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash && - approval.approvalSchemaHash === candidate.approvalSchemaHash && - approval.responseSchemaHash === candidate.responseSchemaHash - ) { - return { - descriptor: interrupt, - binding: cloneAndDeepFreezeJson(candidate), - kind: 'tool-approval', - status: 'pending', - canResolve: true, - resumable: true, - tool, - validationGeneration: 0, - } - } - } catch { - // Invalid configured schemas cannot safely grant typed hydration. - } - } + private hydrateMismatchedFirstPartyGeneric( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + candidate: InterruptBinding | undefined, + structurallyCorrelated: boolean, + ): RuntimeInterrupt | undefined { + if ( + candidate === undefined || + !hasFirstPartyGenericMarker(interrupt) || + (structurallyCorrelated && + candidate.kind === 'generic' && + candidate.definitionId !== undefined && + candidate.key !== undefined && + candidate.batchIndex !== undefined) + ) { + return undefined } + return { + descriptor: interrupt, + binding: genericBinding(interrupt, hydration, candidate), + kind: 'generic', + status: 'error', + canResolve: false, + resumable: false, + error: this.itemError( + interrupt.id, + 'stale', + 'The interrupt binding does not match this interrupted run.', + ), + validationGeneration: 0, + } + } - if (structurallyCorrelated && candidate.kind === 'client-tool-execution') { - const tool = this.tools?.find( - (configured) => configured.name === candidate.toolName, + private hydrateToolApprovalInterrupt( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + structurallyCorrelated: boolean, + ): RuntimeInterrupt | undefined { + if ( + !structurallyCorrelated || + candidate === undefined || + candidate.kind !== 'tool-approval' + ) { + return undefined + } + const tool = this.tools?.find( + (configured) => configured.name === candidate.toolName, + ) + // Gated on the binding and the schema hashes below, not on + // `interrupt.reason` — that string is free-form AG-UI text another + // producer can also use, so it cannot be what decides ownership. + if ( + tool?.needsApproval !== true || + interrupt.toolCallId !== candidate.toolCallId + ) { + return undefined + } + try { + const approval = normalizeApprovalSchema( + tool.approvalSchema, + tool.inputSchema, ) - // Binding-gated, for the same reason as tool approvals above. if ( - tool !== undefined && - interrupt.toolCallId === candidate.toolCallId && - hashSchemaInput(tool.outputSchema) === candidate.outputSchemaHash + hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash && + approval.approvalSchemaHash === candidate.approvalSchemaHash && + approval.responseSchemaHash === candidate.responseSchemaHash ) { return { descriptor: interrupt, binding: cloneAndDeepFreezeJson(candidate), - kind: 'client-tool-execution', + kind: 'tool-approval', status: 'pending', canResolve: true, resumable: true, @@ -866,83 +950,142 @@ export class InterruptManager< validationGeneration: 0, } } + } catch { + // Invalid configured schemas cannot safely grant typed hydration. } + return undefined + } + private hydrateClientToolInterrupt( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + structurallyCorrelated: boolean, + ): RuntimeInterrupt | undefined { if ( - candidate !== undefined && - candidate.kind === 'generic' && - candidate.definitionId !== undefined && - candidate.key !== undefined && - candidate.batchIndex !== undefined && - candidate.key.length > 0 && - firstPartyIndexes.get(candidate.batchIndex) !== 1 + !structurallyCorrelated || + candidate === undefined || + candidate.kind !== 'client-tool-execution' ) { - return { - descriptor: interrupt, - binding: cloneAndDeepFreezeJson(candidate), - kind: 'generic', - status: 'error', - canResolve: false, - resumable: false, - error: this.itemError( - interrupt.id, - 'stale', - 'Generic interrupt batch contains a duplicate batchIndex.', - ), - validationGeneration: 0, - } + return undefined + } + const tool = this.tools?.find( + (configured) => configured.name === candidate.toolName, + ) + // Binding-gated, for the same reason as tool approvals above. + if ( + tool === undefined || + interrupt.toolCallId !== candidate.toolCallId || + hashSchemaInput(tool.outputSchema) !== candidate.outputSchemaHash + ) { + return undefined + } + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'client-tool-execution', + status: 'pending', + canResolve: true, + resumable: true, + tool, + validationGeneration: 0, } + } + private hydrateDuplicateGenericBatch( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + firstPartyIndexes: ReadonlyMap, + ): RuntimeInterrupt | undefined { if ( - correlated && - candidate.kind === 'generic' && - candidate.definitionId !== undefined && - candidate.key !== undefined && - candidate.batchIndex !== undefined && - candidate.key.length > 0 && + candidate === undefined || + candidate.kind !== 'generic' || + candidate.definitionId === undefined || + candidate.key === undefined || + candidate.batchIndex === undefined || + candidate.key.length === 0 || firstPartyIndexes.get(candidate.batchIndex) === 1 ) { - const definition = this.interruptDefinitions.get(candidate.definitionId) - if ( - definition !== undefined && - definitionSchemaHash(definition.responseSchema) === - candidate.responseSchemaHash && - (definition.payloadSchema === undefined - ? candidate.payloadSchemaHash === undefined - : candidate.payloadSchemaHash === - definitionSchemaHash(definition.payloadSchema)) - ) { - const rawPayload = getInterruptPayload(interrupt) - // First-party display payloads are parsed by definition.interrupt() - // before the server emits them. Re-validating here would feed schema - // output back through an input schema and reject transforms such as - // z.string().transform(Number). The checks above still bind this value - // to the exact descriptor, run, generation, definition, and schemas. - return { - descriptor: interrupt, - binding: cloneAndDeepFreezeJson(candidate), - definition, - kind: 'generic', - status: 'pending', - canResolve: true, - resumable: true, - ...(rawPayload === undefined - ? {} - : { payload: cloneAndDeepFreezeJson(rawPayload) }), - validationGeneration: 0, - } - } + return undefined + } + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'generic', + status: 'error', + canResolve: false, + resumable: false, + error: this.itemError( + interrupt.id, + 'stale', + 'Generic interrupt batch contains a duplicate batchIndex.', + ), + validationGeneration: 0, } + } - const resumable = - legacyResumable || - (candidate !== undefined && - candidate.interruptId === interrupt.id && - candidate.interruptedRunId === hydration.interruptedRunId && - candidate.generation === hydration.generation && - (candidate.kind !== 'generic' || - responseSchemaHash(interrupt) === undefined || - candidate.responseSchemaHash === responseSchemaHash(interrupt))) + private hydrateFirstPartyGenericInterrupt( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + correlated: boolean, + firstPartyIndexes: ReadonlyMap, + ): RuntimeInterrupt | undefined { + if ( + !correlated || + candidate === undefined || + candidate.kind !== 'generic' || + candidate.definitionId === undefined || + candidate.key === undefined || + candidate.batchIndex === undefined || + candidate.key.length === 0 || + firstPartyIndexes.get(candidate.batchIndex) !== 1 + ) { + return undefined + } + const definition = this.interruptDefinitions.get(candidate.definitionId) + if ( + definition === undefined || + definitionSchemaHash(definition.responseSchema) !== + candidate.responseSchemaHash || + (definition.payloadSchema === undefined + ? candidate.payloadSchemaHash !== undefined + : candidate.payloadSchemaHash !== + definitionSchemaHash(definition.payloadSchema)) + ) { + return undefined + } + const rawPayload = getInterruptPayload(interrupt) + // First-party display payloads are parsed by definition.interrupt() + // before the server emits them. Re-validating here would feed schema + // output back through an input schema and reject transforms such as + // z.string().transform(Number). The checks above still bind this value + // to the exact descriptor, run, generation, definition, and schemas. + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + definition, + kind: 'generic', + status: 'pending', + canResolve: true, + resumable: true, + ...(rawPayload === undefined + ? {} + : { payload: cloneAndDeepFreezeJson(rawPayload) }), + validationGeneration: 0, + } + } + + private hydrateGenericFallbackInterrupt( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + candidate: InterruptBinding | undefined, + legacyResumable: boolean, + ): RuntimeInterrupt { + const resumable = isGenericFallbackResumable( + legacyResumable, + candidate, + interrupt, + hydration, + ) return { descriptor: interrupt, binding: genericBinding(interrupt, hydration, candidate), diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 7ae11c2fc1..0ed47a6a84 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -370,8 +370,10 @@ export class VideoGenerationClient { fallbackRunId: string, signal: AbortSignal, ): Promise { - let streamRunId: string | undefined - let sawTerminalChunk = false + const state = { + streamRunId: undefined as string | undefined, + sawTerminalChunk: false, + } for await (const raw of source) { if (signal.aborted) break @@ -379,69 +381,79 @@ export class VideoGenerationClient { const chunk = restoreInboundChunk(raw) this.callbacksRef.onChunk?.(chunk) this.observeResumeSnapshot(chunk) - const chunkRunId = - 'runId' in chunk && typeof chunk.runId === 'string' - ? chunk.runId - : undefined - - // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType has ~22 variants; this consumer only handles the subset relevant to video generation lifecycle. - switch (chunk.type) { - case 'RUN_STARTED': { - streamRunId = chunk.runId - this.devtoolsBridge.ensureRunStarted(chunk.runId) - break - } - case 'CUSTOM': { - this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId) - if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { - const { jobId } = chunk.value as { jobId: string } - this.setJobId(jobId) - this.callbacksRef.onJobCreated?.(jobId) - } else if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) { - const statusInfo = chunk.value as VideoStatusInfo - this.setVideoStatus(statusInfo) - this.callbacksRef.onStatusUpdate?.(statusInfo) - if (statusInfo.progress !== undefined) { - this.setProgress(statusInfo.progress) - } - } else if (chunk.name === GENERATION_EVENTS.RESULT) { - this.setResult(chunk.value as VideoGenerateResult) - } else if (chunk.name === GENERATION_EVENTS.PROGRESS) { - const { progress, message } = chunk.value as { - progress: number - message?: string - } - this.setProgress(progress, message) - } - break - } - case 'RUN_FINISHED': { - streamRunId = chunk.runId - sawTerminalChunk = true - this.devtoolsBridge.ensureRunStarted(chunk.runId) - this.setStatus('success') - break - } - case 'RUN_ERROR': { - this.devtoolsBridge.ensureRunStarted( - chunkRunId ?? streamRunId ?? fallbackRunId, - ) - // Spec RUN_ERROR message. Missing message uses this fallback. - const msg = - (chunk.message as string | undefined) || 'An error occurred' - throw new Error(msg) - } - default: - break - } + this.applyVideoStreamChunk(chunk, fallbackRunId, state) } // An aborted read is a deliberate stop/dispose, not a truncation. - if (!sawTerminalChunk && !signal.aborted) { + if (!state.sawTerminalChunk && !signal.aborted) { throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE) } } + private applyVideoStreamChunk( + chunk: StreamChunk, + fallbackRunId: string, + state: { streamRunId: string | undefined; sawTerminalChunk: boolean }, + ): void { + if (chunk.type === 'RUN_STARTED') { + state.streamRunId = chunk.runId + this.devtoolsBridge.ensureRunStarted(chunk.runId) + return + } + if (chunk.type === 'CUSTOM') { + this.applyVideoCustomChunk(chunk, state.streamRunId ?? fallbackRunId) + return + } + if (chunk.type === 'RUN_FINISHED') { + state.streamRunId = chunk.runId + state.sawTerminalChunk = true + this.devtoolsBridge.ensureRunStarted(chunk.runId) + this.setStatus('success') + return + } + if (chunk.type !== 'RUN_ERROR') return + const chunkRunId = + 'runId' in chunk && typeof chunk.runId === 'string' + ? chunk.runId + : undefined + this.devtoolsBridge.ensureRunStarted( + chunkRunId ?? state.streamRunId ?? fallbackRunId, + ) + // Spec RUN_ERROR message. Missing message uses this fallback. + const msg = (chunk.message as string | undefined) || 'An error occurred' + throw new Error(msg) + } + + private applyVideoCustomChunk(chunk: StreamChunk, runId: string): void { + this.devtoolsBridge.ensureRunStarted(runId) + if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { + const { jobId } = chunk.value as { jobId: string } + this.setJobId(jobId) + this.callbacksRef.onJobCreated?.(jobId) + return + } + if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) { + const statusInfo = chunk.value as VideoStatusInfo + this.setVideoStatus(statusInfo) + this.callbacksRef.onStatusUpdate?.(statusInfo) + if (statusInfo.progress !== undefined) { + this.setProgress(statusInfo.progress) + } + return + } + if (chunk.name === GENERATION_EVENTS.RESULT) { + this.setResult(chunk.value as VideoGenerateResult) + return + } + if (chunk.name === GENERATION_EVENTS.PROGRESS) { + const { progress, message } = chunk.value as { + progress: number + message?: string + } + this.setProgress(progress, message) + } + } + /** * Abort any in-flight generation or polling. */ diff --git a/packages/ai-code-mode/src/create-code-mode-tool.ts b/packages/ai-code-mode/src/create-code-mode-tool.ts index 1c1213d5fe..d831142489 100644 --- a/packages/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/ai-code-mode/src/create-code-mode-tool.ts @@ -199,139 +199,27 @@ export function createCodeModeTool( }) try { - // Step 1: Strip TypeScript (also serves as syntax validation via the - // transpiler — sucrase by default, or a user-supplied `transpile`) - let strippedCode: string - try { - strippedCode = await transpile(typescriptCode) - } catch (error) { - // Type/syntax error from the transpiler - return finish( - { - success: false, - error: { - message: error instanceof Error ? error.message : String(error), - name: 'TypeScriptError', - ...(error instanceof Error && - error.stack !== undefined && { stack: error.stack }), - }, - }, - 'transpile', - ) - } - - // Step 2: Get dynamic snippet bindings if available - const snippetBindings = getSnippetBindings - ? await getSnippetBindings() - : {} - - // Scan dynamic bindings too — their schemas are equally in-scope for - // the same exfiltration threat. Dedup cache prevents repeat warnings - // when the same binding reappears across executions. - const snippetBindingValues = Object.values(snippetBindings) - if (snippetBindingValues.length > 0) { - warnIfBindingsExposeSecrets(snippetBindingValues, { - handler: onSecretParameter, - dedupCache: secretDedupCache, - }) - } - - // Step 3: Merge static and dynamic bindings, then wrap with event awareness - const allBindings = { ...staticBindings, ...snippetBindings } - const eventAwareBindings = createEventAwareBindings( - allBindings, + return await runCodeModeExecution({ + typescriptCode, + transpile, + getSnippetBindings, + onSecretParameter, + secretDedupCache, + staticBindings, emitCustomEvent, - ) - - // Step 4: Create sandbox context with event-aware bindings - try { - isolateContext = await driver.createContext({ - bindings: eventAwareBindings, - timeout, - memoryLimit, - }) - } catch (error) { - return finish( - { - success: false, - error: { - message: error instanceof Error ? error.message : String(error), - name: - error instanceof Error ? error.name : 'CreateContextError', - ...(error instanceof Error && - error.stack !== undefined && { stack: error.stack }), - }, - }, - 'create-context', - ) - } - - // Step 5: Execute the code in the sandbox - const executionResult = await isolateContext.execute(strippedCode) - - // Emit console logs as custom events - if (executionResult.logs && executionResult.logs.length > 0) { - for (const log of executionResult.logs) { - // Parse log level from prefix (added by sandbox console implementation) - let level: 'log' | 'warn' | 'error' | 'info' = 'log' - let message = log - - if (log.startsWith('ERROR: ')) { - level = 'error' - message = log.slice(7) - } else if (log.startsWith('WARN: ')) { - level = 'warn' - message = log.slice(6) - } else if (log.startsWith('INFO: ')) { - level = 'info' - message = log.slice(6) - } - - emitCustomEvent('code_mode:console', { - level, - message, - timestamp: Date.now(), - }) - } - } - - if (executionResult.success) { - return finish( - { - success: true, - result: executionResult.value, - logs: executionResult.logs, - }, - 'execute', - ) - } - - return finish( - { - success: false, - error: executionResult.error - ? { - message: executionResult.error.message, - name: executionResult.error.name, - ...(executionResult.error.stack !== undefined && { - stack: executionResult.error.stack, - }), - } - : { message: 'Unknown execution error', name: 'UnknownError' }, - logs: executionResult.logs, + driver, + timeout, + memoryLimit, + setIsolateContext: (next) => { + isolateContext = next }, - 'execute', - ) + finish, + }) } catch (error) { return finish( { success: false, - error: { - message: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : 'Error', - ...(error instanceof Error && - error.stack !== undefined && { stack: error.stack }), - }, + error: codeModeCaughtError(error), }, 'unhandled', ) @@ -348,6 +236,142 @@ export function createCodeModeTool( /** * Build the tool description including available external functions */ +function codeModeCaughtError( + error: unknown, +): NonNullable { + return { + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : 'Error', + ...(error instanceof Error && + error.stack !== undefined && { stack: error.stack }), + } +} + +function emitCodeModeConsoleLogs( + logs: Array | undefined, + emitCustomEvent: (name: string, payload: unknown) => void, +): void { + if (!logs || logs.length === 0) return + for (const log of logs) { + const parsed = parseCodeModeLog(log) + emitCustomEvent('code_mode:console', { + ...parsed, + timestamp: Date.now(), + }) + } +} + +function parseCodeModeLog(log: string): { + level: 'log' | 'warn' | 'error' | 'info' + message: string +} { + if (log.startsWith('ERROR: ')) + return { level: 'error', message: log.slice(7) } + if (log.startsWith('WARN: ')) return { level: 'warn', message: log.slice(6) } + if (log.startsWith('INFO: ')) return { level: 'info', message: log.slice(6) } + return { level: 'log', message: log } +} + +async function runCodeModeExecution(args: { + typescriptCode: string + transpile: (code: string) => Promise | string + getSnippetBindings: CodeModeToolConfig['getSnippetBindings'] + onSecretParameter: CodeModeToolConfig['onSecretParameter'] + secretDedupCache: Set + staticBindings: ReturnType + emitCustomEvent: (name: string, payload: unknown) => void + driver: CodeModeToolConfig['driver'] + timeout: number + memoryLimit: number + setIsolateContext: (context: IsolateContext) => void + finish: (result: CodeModeToolResult, phase: string) => CodeModeToolResult +}): Promise { + let strippedCode: string + try { + strippedCode = await args.transpile(args.typescriptCode) + } catch (error) { + return args.finish( + { + success: false, + error: { + ...codeModeCaughtError(error), + name: 'TypeScriptError', + }, + }, + 'transpile', + ) + } + + const snippetBindings = args.getSnippetBindings + ? await args.getSnippetBindings() + : {} + const snippetBindingValues = Object.values(snippetBindings) + if (snippetBindingValues.length > 0) { + warnIfBindingsExposeSecrets(snippetBindingValues, { + handler: args.onSecretParameter, + dedupCache: args.secretDedupCache, + }) + } + + const eventAwareBindings = createEventAwareBindings( + { ...args.staticBindings, ...snippetBindings }, + args.emitCustomEvent, + ) + + let isolateContext: IsolateContext + try { + isolateContext = await args.driver.createContext({ + bindings: eventAwareBindings, + timeout: args.timeout, + memoryLimit: args.memoryLimit, + }) + } catch (error) { + const caught = codeModeCaughtError(error) + return args.finish( + { + success: false, + error: { + ...caught, + name: error instanceof Error ? error.name : 'CreateContextError', + }, + }, + 'create-context', + ) + } + args.setIsolateContext(isolateContext) + + const executionResult = await isolateContext.execute(strippedCode) + emitCodeModeConsoleLogs(executionResult.logs, args.emitCustomEvent) + + if (executionResult.success) { + return args.finish( + { + success: true, + result: executionResult.value, + logs: executionResult.logs, + }, + 'execute', + ) + } + + return args.finish( + { + success: false, + error: executionResult.error + ? { + message: executionResult.error.message, + name: executionResult.error.name, + ...(executionResult.error.stack !== undefined && { + stack: executionResult.error.stack, + }), + } + : { message: 'Unknown execution error', name: 'UnknownError' }, + logs: executionResult.logs, + }, + 'execute', + ) +} + function buildToolDescription(tools: Array): string { const eager = tools.filter((t) => !t.lazy) const hasLazy = tools.some((t) => t.lazy) diff --git a/packages/ai-codex/src/adapters/text.ts b/packages/ai-codex/src/adapters/text.ts index c5a9a3cac4..bf908d999b 100644 --- a/packages/ai-codex/src/adapters/text.ts +++ b/packages/ai-codex/src/adapters/text.ts @@ -39,6 +39,36 @@ import type { CodexModel } from '../model-meta' import type { CodexTextProviderOptions } from '../provider-options' import type { CodexThreadEvent } from '../stream/sdk-types' +function chatRunErrorChunk(error: unknown, model: string): AdapterYieldChunk { + const err = error as Error & { code?: string } + const rawEvent = toRunErrorRawEvent(error) + const message = err.message || 'Unknown error occurred' + return { + type: EventType.RUN_ERROR, + model, + timestamp: Date.now(), + message, + ...(err.code !== undefined && { code: err.code }), + ...(rawEvent !== undefined && { rawEvent }), + error: { + message, + ...(err.code !== undefined && { code: err.code }), + }, + } +} + +function abortSignalFields( + options: TextOptions, +): { signal: AbortSignal } | Record { + if (options.abortController?.signal) { + return { signal: options.abortController.signal } + } + if (options.request?.signal) { + return { signal: options.request.signal } + } + return {} +} + export type CodexSandboxMode = | 'read-only' | 'workspace-write' @@ -195,12 +225,47 @@ export class CodexTextAdapter< // literal `--cd` makes codex chdir to a path that doesn't exist on the real // filesystem → "No such file or directory (os error 2)". Codex inherits the // handle-set process cwd instead. + this.pushCodexDirFlags(args, skipGitRepoCheck, config.additionalDirectories) + + const cfg = this.codexConfigFlags( + approvalPolicy, + reasoning, + networkAccessEnabled, + bridge, + ) + for (const [key, value] of Object.entries(cfg)) { + args.push('--config', q(`${key}=${value}`)) + } + + if (outputSchemaPath !== undefined) { + args.push('--output-schema', q(outputSchemaPath)) + } + + // Resume an existing thread (mirrors the SDK's `resume `). + if (resume !== undefined) args.push('resume', q(resume)) + + return `${exe} ${args.join(' ')}` + } + + private pushCodexDirFlags( + args: Array, + skipGitRepoCheck: boolean | undefined, + additionalDirectories: Array | undefined, + ): void { if (skipGitRepoCheck !== false) args.push('--skip-git-repo-check') - for (const dir of config.additionalDirectories ?? []) { + for (const dir of additionalDirectories ?? []) { args.push('--add-dir', q(dir)) } + } - const cfg: Record = { + private codexConfigFlags( + approvalPolicy: string, + reasoning: string | undefined, + networkAccessEnabled: boolean | undefined, + bridge: HostToolBridge | undefined, + ): Record { + const config = this.adapterConfig + return { approval_policy: `"${approvalPolicy}"`, ...(reasoning ? { model_reasoning_effort: `"${reasoning}"` } : {}), ...(networkAccessEnabled !== undefined @@ -226,18 +291,56 @@ export class CodexTextAdapter< : {}), ...config.config, } - for (const [key, value] of Object.entries(cfg)) { - args.push('--config', q(`${key}=${value}`)) - } + } - if (outputSchemaPath !== undefined) { - args.push('--output-schema', q(outputSchemaPath)) - } + private async maybeProvisionCodexBridge( + options: TextOptions, + sandbox: SandboxHandle, + channel: ReturnType, + ): Promise { + if (!options.tools || options.tools.length === 0) return undefined + const provisioner = + (options.capabilities + ? getToolBridgeProvisioner(options.capabilities, { optional: true }) + : undefined) ?? nodeHttpBridgeProvisioner + return await provisioner.provision(options.tools, { + provider: sandbox.provider, + context: options.context, + emitCustomEvent: channel.emitCustomEvent, + ...(options.abortController?.signal + ? { signal: options.abortController.signal } + : {}), + }) + } - // Resume an existing thread (mirrors the SDK's `resume `). - if (resume !== undefined) args.push('resume', q(resume)) + private codexPrompt( + options: TextOptions, + prompt: string, + ): string { + const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + .map((p) => p.content) + .filter((c) => c.trim() !== '') + if (systemPrompts.length === 0) return prompt + return `${systemPrompts.join('\n\n')}\n\n${prompt}` + } - return `${exe} ${args.join(' ')}` + private async prepareCodexStdin( + sandbox: SandboxHandle, + command: string, + fullPrompt: string, + runId: string, + tempFiles: Array, + ): Promise<{ runCommand: string; stdinInput: string | undefined }> { + if (sandbox.capabilities.writableStdin !== false) { + return { runCommand: command, stdinInput: fullPrompt } + } + const promptPath = `/tmp/tanstack-codex-prompt-${encodeRunId(runId)}` + await sandbox.fs.write(promptPath, fullPrompt) + tempFiles.push(promptPath) + return { + runCommand: `${command} < ${q(promptPath)}`, + stdinInput: undefined, + } } async *chatStream( @@ -294,32 +397,13 @@ export class CodexTextAdapter< : undefined if (projection) await projectCodexWorkspace(sandbox, projection) - if (options.tools && options.tools.length > 0) { - const provisioner = - (options.capabilities - ? getToolBridgeProvisioner(options.capabilities, { optional: true }) - : undefined) ?? nodeHttpBridgeProvisioner - bridge = await provisioner.provision(options.tools, { - provider: sandbox.provider, - context: options.context, - emitCustomEvent: channel.emitCustomEvent, - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : {}), - }) - } + bridge = await this.maybeProvisionCodexBridge(options, sandbox, channel) const { prompt, resume } = buildPrompt( options.messages, options.modelOptions?.sessionId, ) - const systemPrompts = normalizeSystemPrompts(options.systemPrompts) - .map((p) => p.content) - .filter((c) => c.trim() !== '') - const fullPrompt = - systemPrompts.length > 0 - ? `${systemPrompts.join('\n\n')}\n\n${prompt}` - : prompt + const fullPrompt = this.codexPrompt(options, prompt) const policy = options.capabilities ? getSandboxPolicy(options.capabilities, { optional: true }) @@ -351,29 +435,13 @@ export class CodexTextAdapter< // stdout when stdin EOF is signalled (losing the agent's output), and // Cloudflare can't write stdin at all — so feed the prompt from a file // (`codex exec … < file`) instead. - let runCommand = command - let stdinInput: string | undefined = fullPrompt - if (sandbox.capabilities.writableStdin === false) { - // Reuse the ALREADY-RESOLVED `runId`, not a fresh `options.runId ?? this.generateId()` - // re-derivation: the latter mints a SECOND random id whenever - // `options.runId` is absent, so the prompt file's suffix would not - // even match the journal path derived from the run's own `runId` - // above (see `resolveDurableRunId`). That mismatch is invisible - // (the prompt still gets read), but it defeats the whole point of a - // stable, caller-supplied `runId` for anything keyed off it. - // `encodeRunId`, because durability makes `runId` CALLER-chosen and this - // interpolates it into a filesystem path. Raw, a `/` would silently turn - // the basename into a nested path (writing outside `/tmp` or failing on a - // missing dir), `..` would climb out of it, and a long id would fail the - // spawn with `ENAMETOOLONG`. The encoder collapses every id to one - // bounded, injective path segment — the same one `journalPaths` uses, so - // the prompt file and the journal agree on how this id spells. - const promptPath = `/tmp/tanstack-codex-prompt-${encodeRunId(runId)}` - await sandbox.fs.write(promptPath, fullPrompt) - tempFiles.push(promptPath) - runCommand = `${command} < ${q(promptPath)}` - stdinInput = undefined - } + const prepared = await this.prepareCodexStdin( + sandbox, + command, + fullPrompt, + runId, + tempFiles, + ) // `undefined` whenever the run is not durable, so `spawnNdjson` takes its // original, unjournaled path and behavior stays byte-identical to a @@ -383,15 +451,13 @@ export class CodexTextAdapter< // never by an application's POST handler (see `SandboxDurabilityOptions.attach`). const journalOptions = journalOptionsFor(durability, runId) - const rawEvents = spawnNdjson(sandbox, runCommand, { + const rawEvents = spawnNdjson(sandbox, prepared.runCommand, { cwd, - ...(stdinInput !== undefined ? { input: stdinInput } : {}), + ...(prepared.stdinInput !== undefined + ? { input: prepared.stdinInput } + : {}), ...(this.adapterConfig.env ? { env: this.adapterConfig.env } : {}), - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : options.request?.signal - ? { signal: options.request.signal } - : {}), + ...abortSignalFields(options), onNonJsonLine: (line) => logger.provider(`provider=codex non-json line: ${line}`, { chunk: line, @@ -450,24 +516,11 @@ export class CodexTextAdapter< logger, ) } catch (error: unknown) { - const err = error as Error & { code?: string } - const rawEvent = toRunErrorRawEvent(error) logger.errors('codex.chatStream fatal', { error, source: 'codex.chatStream', }) - yield { - type: EventType.RUN_ERROR, - model: options.model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - }, - } + yield chatRunErrorChunk(error, options.model) } finally { channel.close() await bridge?.close() diff --git a/packages/ai-cohere/src/adapters/embedding.ts b/packages/ai-cohere/src/adapters/embedding.ts index 551736b81e..a8b72e7038 100644 --- a/packages/ai-cohere/src/adapters/embedding.ts +++ b/packages/ai-cohere/src/adapters/embedding.ts @@ -56,6 +56,24 @@ function isPrivateOrInternalUrl(url: string): boolean { return false } +async function readCohereErrorMessage(response: Response): Promise { + const bodyText = await response.text() + try { + const parsed: unknown = JSON.parse(bodyText) + if ( + typeof parsed === 'object' && + parsed !== null && + 'message' in parsed && + typeof parsed.message === 'string' + ) { + return parsed.message + } + } catch { + // Not JSON — fall back to the raw body text. + } + return bodyText +} + async function fetchWithTimeout( url: string, init: RequestInit | undefined, @@ -146,20 +164,8 @@ export class CohereEmbeddingAdapter< ) } - const resolved = resolveEmbeddingInput(options.input) - const inputs = await Promise.all( - resolved.map(async (item) => { - const content: Array = item.texts.map( - (text) => ({ type: 'text', text }), - ) - for (const image of item.images) { - content.push({ - type: 'image_url', - image_url: { url: await this.resolveImageUrl(image) }, - }) - } - return { content } - }), + const inputs = await this.toCohereInputs( + resolveEmbeddingInput(options.input), ) // embedding_types is pinned to ['float'] (overriding any disagreeing @@ -199,22 +205,9 @@ export class CohereEmbeddingAdapter< ) if (!response.ok) { - const bodyText = await response.text() - let message = bodyText - try { - const parsed: unknown = JSON.parse(bodyText) - if ( - typeof parsed === 'object' && - parsed !== null && - 'message' in parsed && - typeof parsed.message === 'string' - ) { - message = parsed.message - } - } catch { - // Not JSON — fall back to the raw body text. - } - throw new Error(`Cohere embed failed (${response.status}): ${message}`) + throw new Error( + `Cohere embed failed (${response.status}): ${await readCohereErrorMessage(response)}`, + ) } const data = (await response.json()) as CohereEmbedResponse @@ -257,6 +250,25 @@ export class CohereEmbeddingAdapter< } } + private async toCohereInputs( + resolved: ReturnType, + ): Promise }>> { + return Promise.all( + resolved.map(async (item) => { + const content: Array = item.texts.map( + (text) => ({ type: 'text', text }), + ) + for (const image of item.images) { + content.push({ + type: 'image_url', + image_url: { url: await this.resolveImageUrl(image) }, + }) + } + return { content } + }), + ) + } + /** * Resolves an image part to a URL Cohere accepts. Cohere does not fetch * remote image URLs, so everything is normalized to a `data:` URI unless diff --git a/packages/ai-devtools/src/components/hooks/HookDetails.tsx b/packages/ai-devtools/src/components/hooks/HookDetails.tsx index 0ca0c48f98..fdb4abe6a3 100644 --- a/packages/ai-devtools/src/components/hooks/HookDetails.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDetails.tsx @@ -1425,6 +1425,112 @@ function partsFromUnknown( .filter(isPreviewPart) } +function stringFieldOr( + value: unknown, + fallback: unknown, + defaultValue: string, +): string { + if (typeof value === 'string') return value + if (typeof fallback === 'string') return fallback + return defaultValue +} + +function previewToolCallPart( + part: PreviewPartSource, + index: number, + messageId?: string, +): PreviewPart { + const name = stringFieldOr(part.name, part.toolName, 'tool') + const rawId = stringFieldOr(part.toolCallId, part.id, `${index}:${name}`) + const input = part.input ?? part.arguments + const output = part.output + const parsedInput = input === undefined ? {} : parseJsonishValue(input) + const parsedOutput = + output === undefined ? undefined : parseJsonishValue(output) + const approvalStatus = approvalStatusFromRecord(part.approval, part.state) + const jsonItems: Array = [] + if (input !== undefined) { + jsonItems.push({ + label: 'Input', + value: parsedInput, + }) + } + if (part.approval !== undefined) { + jsonItems.push({ + label: 'Approval', + value: parseJsonishValue(part.approval), + }) + } + if (output !== undefined) { + jsonItems.push({ + label: 'Output', + value: parsedOutput, + }) + } + return { + id: toolCallPartId(rawId), + label: approvalStatus + ? `tool call ${name} - ${approvalStatus}` + : `tool call ${name}`, + content: formatUnknown(input ?? output), + jsonItems, + kind: 'tool-call', + fixture: { + toolName: name, + input: parsedInput, + ...(parsedOutput !== undefined ? { output: parsedOutput } : {}), + toolCallId: rawId, + ...(messageId ? { messageId } : {}), + }, + } +} + +function previewToolResultPart( + part: PreviewPartSource, + index: number, +): PreviewPart { + const name = + typeof part.name === 'string' + ? part.name + : typeof part.toolName === 'string' + ? part.toolName + : undefined + const rawId = stringFieldOr(part.toolCallId, part.id, `${index}`) + const output = part.output ?? part.content ?? part.error + return { + id: toolResultPartId(rawId), + label: name ? `tool result ${name}` : 'tool result', + content: formatUnknown(output), + jsonItems: + output === undefined + ? [] + : [ + { + label: part.error ? 'Error' : 'Output', + value: parseJsonishValue(output), + }, + ], + kind: 'tool-result', + } +} + +function previewStructuredOutputPart( + part: PreviewPartSource, + index: number, + messageId?: string, +): PreviewPart { + const status = typeof part.status === 'string' ? part.status : undefined + const raw = typeof part.raw === 'string' ? part.raw : undefined + + return { + id: structuredOutputPartId(messageId ?? `${index}`), + label: status ? `structured output - ${status}` : 'structured output', + content: raw ?? formatUnknown(part.data ?? part.partial), + jsonItems: structuredOutputJsonItems(part), + kind: 'structured-output', + } +} + function previewPartFromRecord( part: PreviewPartSource, index: number, @@ -1432,101 +1538,13 @@ function previewPartFromRecord( ): PreviewPart { const type = typeof part.type === 'string' ? part.type : 'part' if (type === 'tool-call') { - const name = - typeof part.name === 'string' - ? part.name - : typeof part.toolName === 'string' - ? part.toolName - : 'tool' - const rawId = - typeof part.toolCallId === 'string' - ? part.toolCallId - : typeof part.id === 'string' - ? part.id - : `${index}:${name}` - const input = part.input ?? part.arguments - const output = part.output - const parsedInput = input === undefined ? {} : parseJsonishValue(input) - const parsedOutput = - output === undefined ? undefined : parseJsonishValue(output) - const approvalStatus = approvalStatusFromRecord(part.approval, part.state) - const jsonItems: Array = [] - if (input !== undefined) { - jsonItems.push({ - label: 'Input', - value: parsedInput, - }) - } - if (part.approval !== undefined) { - jsonItems.push({ - label: 'Approval', - value: parseJsonishValue(part.approval), - }) - } - if (output !== undefined) { - jsonItems.push({ - label: 'Output', - value: parsedOutput, - }) - } - return { - id: toolCallPartId(rawId), - label: approvalStatus - ? `tool call ${name} - ${approvalStatus}` - : `tool call ${name}`, - content: formatUnknown(input ?? output), - jsonItems, - kind: 'tool-call', - fixture: { - toolName: name, - input: parsedInput, - ...(parsedOutput !== undefined ? { output: parsedOutput } : {}), - toolCallId: rawId, - ...(messageId ? { messageId } : {}), - }, - } + return previewToolCallPart(part, index, messageId) } if (type === 'tool-result') { - const name = - typeof part.name === 'string' - ? part.name - : typeof part.toolName === 'string' - ? part.toolName - : undefined - const rawId = - typeof part.toolCallId === 'string' - ? part.toolCallId - : typeof part.id === 'string' - ? part.id - : `${index}` - const output = part.output ?? part.content ?? part.error - return { - id: toolResultPartId(rawId), - label: name ? `tool result ${name}` : 'tool result', - content: formatUnknown(output), - jsonItems: - output === undefined - ? [] - : [ - { - label: part.error ? 'Error' : 'Output', - value: parseJsonishValue(output), - }, - ], - kind: 'tool-result', - } + return previewToolResultPart(part, index) } if (type === 'structured-output') { - const status = typeof part.status === 'string' ? part.status : undefined - const raw = typeof part.raw === 'string' ? part.raw : undefined - - return { - id: structuredOutputPartId(messageId ?? `${index}`), - label: status ? `structured output - ${status}` : 'structured output', - content: raw ?? formatUnknown(part.data ?? part.partial), - jsonItems: structuredOutputJsonItems(part), - kind: 'structured-output', - } + return previewStructuredOutputPart(part, index, messageId) } if (type === 'thinking') { return { diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index d3e4e35e0f..b0683c83f1 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -29,7 +29,10 @@ import type { ContentPartSource, TokenUsage } from '@tanstack/ai' import type { ContentPart, DevtoolsToolFixtureApplyEvent, + MessagePart as EventMessagePart, RunLifecycleEvent, + TextMessageCreatedEvent, + ToolsApprovalRequestedEvent, } from '@tanstack/ai-event-client' import type { HookRegistryState, ToolFixtureRecord } from './hook-registry' import type { MemoryRegistryState } from './memory-registry' @@ -258,6 +261,171 @@ interface AIContextValue { applyToolFixture: (fixture: ToolFixtureRecord) => void } +function chatConversationKind( + conversationId: string, + clientId: string | undefined, + source: 'client' | 'server', +): 'client' | 'server' { + return conversationId === clientId && source === 'client' + ? 'client' + : 'server' +} + +function chatConversationLabel( + conversationId: string, + type: 'client' | 'server', +): string { + return type === 'client' + ? `Client Chat (${conversationId.substring(0, 8)})` + : `Server Chat (${conversationId.substring(0, 8)})` +} + +function stringifyToolArguments(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value ?? {}) + } catch (error) { + console.error( + '[ai-devtools] failed to JSON.stringify tool call arguments; saved fixture replay will be malformed.', + { error, value }, + ) + return `[ai-devtools] unserializable tool arguments: ${ + error instanceof Error ? error.message : String(error) + }` + } +} + +function eventPartToMessagePart(part: EventMessagePart): MessagePart | null { + if (part.type === 'text') { + return { type: 'text', content: part.content } + } + if (part.type === 'tool-call') { + return { + type: 'tool-call', + toolCallId: part.id, + toolName: part.name, + arguments: part.arguments, + state: part.state, + output: part.output, + approval: part.approval, + content: part.approval ? JSON.stringify(part.approval) : undefined, + } + } + if (part.type === 'tool-result') { + return { + type: 'tool-result', + toolCallId: part.toolCallId, + content: part.content, + state: part.state, + error: part.error, + } + } + if (part.type === 'thinking') { + return { + type: 'thinking', + content: part.content, + } + } + if (part.type === 'structured-output') { + return { + type: 'structured-output', + status: part.status, + raw: part.raw, + partial: part.partial, + data: part.data, + reasoning: part.reasoning, + errorMessage: part.errorMessage, + } + } + if (part.type === 'image' || part.type === 'audio' || part.type === 'video') { + return { + type: part.type, + source: part.source, + metadata: part.metadata, + } + } + return null +} + +function isStoreMessagePart(part: MessagePart | null): part is MessagePart { + return part !== null +} + +function partsFromCreatedEvent( + parts: Array | undefined, +): Array { + return parts?.map(eventPartToMessagePart).filter(isStoreMessagePart) ?? [] +} + +function toolCallsFromCreatedParts( + parts: Array, + messageId: string, +): Array { + return parts + .filter((part) => part.type === 'tool-call') + .map((part) => ({ + id: part.toolCallId ?? `${messageId}:${part.toolName ?? 'tool'}`, + name: part.toolName ?? 'tool', + arguments: part.arguments ?? stringifyToolArguments(part.output), + state: part.state ?? 'input-complete', + ...(part.output !== undefined ? { result: part.output } : {}), + ...(part.approval?.needsApproval !== undefined + ? { approvalRequired: part.approval.needsApproval } + : {}), + ...(part.approval?.id ? { approvalId: part.approval.id } : {}), + ...(part.approval?.approved !== undefined + ? { approvalApproved: part.approval.approved } + : {}), + })) +} + +function createdMessageToolCalls( + payload: TextMessageCreatedEvent, + parts: Array, + messageId: string, +): Array | undefined { + const toolCallsFromPayload = payload.toolCalls?.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + state: 'input-complete', + })) + const toolCallsFromParts = toolCallsFromCreatedParts(parts, messageId) + if (toolCallsFromPayload && toolCallsFromPayload.length > 0) { + return toolCallsFromPayload + } + if (toolCallsFromParts.length > 0) { + return toolCallsFromParts + } + return undefined +} + +function structuredOutputFields(payload: { + raw?: string + partial?: unknown + data?: unknown + reasoning?: string + errorMessage?: string +}): { + raw: string + partial?: unknown + data?: unknown + reasoning?: string + errorMessage?: string +} { + return { + raw: payload.raw ?? '', + ...(payload.partial !== undefined ? { partial: payload.partial } : {}), + ...(payload.data !== undefined ? { data: payload.data } : {}), + ...(payload.reasoning !== undefined + ? { reasoning: payload.reasoning } + : {}), + ...(payload.errorMessage !== undefined + ? { errorMessage: payload.errorMessage } + : {}), + } +} + const AIContext = createContext() export function useAIStore(): AIContextValue { @@ -830,21 +998,6 @@ export const AIProvider: ParentComponent = (props) => { return source === 'client' || source === 'server' ? source : fallback } - function stringifyToolArguments(value: unknown): string { - if (typeof value === 'string') return value - try { - return JSON.stringify(value ?? {}) - } catch (error) { - console.error( - '[ai-devtools] failed to JSON.stringify tool call arguments; saved fixture replay will be malformed.', - { error, value }, - ) - return `[ai-devtools] unserializable tool arguments: ${ - error instanceof Error ? error.message : String(error) - }` - } - } - // Additional optimized helper functions function updateConversation( conversationId: string, @@ -1177,6 +1330,263 @@ export const AIProvider: ParentComponent = (props) => { }) } + function ensureChatConversation( + conversationId: string, + clientId: string | undefined, + source: 'client' | 'server', + ): void { + if (state.conversations[conversationId]) return + const type = chatConversationKind(conversationId, clientId, source) + getOrCreateConversation( + conversationId, + type, + chatConversationLabel(conversationId, type), + ) + } + + function resolveCreatedMessageTarget(payload: TextMessageCreatedEvent): + | { + conversationId: string + conv: Conversation + source: 'client' | 'server' + } + | undefined { + const { clientId, streamId, role } = payload + const conversationId = + clientId || (streamId ? streamToConversation.get(streamId) : undefined) + if (!conversationId) return undefined + if (clientId && streamId) { + streamToConversation.set(streamId, clientId) + } + if (role === 'tool' || role === 'system') return undefined + const source = normalizeMessageSource( + payload.source, + clientId ? 'client' : 'server', + ) + const conversationType = + clientId && source !== 'server' ? 'client' : 'server' + if (!state.conversations[conversationId]) { + getOrCreateConversation( + conversationId, + conversationType, + chatConversationLabel(conversationId, conversationType), + ) + } + const conv = state.conversations[conversationId] + if (!conv) return undefined + return { conversationId, conv, source } + } + + function attachCreatedMessageToIteration( + conv: Conversation, + conversationId: string, + messageId: string, + requestId: string | undefined, + ): void { + if (conv.iterations.length === 0) return + let iterIndex = -1 + if (requestId) { + for (let i = conv.iterations.length - 1; i >= 0; i--) { + if (conv.iterations[i]?.requestId === requestId) { + iterIndex = i + break + } + } + } else { + iterIndex = conv.iterations.length - 1 + } + if (iterIndex < 0) return + const iter = conv.iterations[iterIndex] + if (!iter || iter.messageIds.includes(messageId)) return + setState( + 'conversations', + conversationId, + 'iterations', + iterIndex, + 'messageIds', + produce((arr: Array) => { + arr.push(messageId) + }), + ) + } + + function handleTextMessageCreated(payload: TextMessageCreatedEvent): void { + const resolved = resolveCreatedMessageTarget(payload) + if (!resolved) return + const { conversationId, conv, source } = resolved + const { messageId, role, content, timestamp, requestId } = payload + const existingIndex = conv.messages.findIndex( + (message) => message.id === messageId, + ) + const parts = partsFromCreatedEvent(payload.parts) + const toolCalls = createdMessageToolCalls(payload, parts, messageId) + if (role === 'user' && conv.type === 'client' && source === 'server') { + return + } + if ( + shouldSkipClientAssistantPlaceholder({ + role, + source, + content, + toolCalls, + parts, + }) + ) { + return + } + const messagePayload: Message = { + id: messageId, + role, + content, + timestamp, + parts, + toolCalls, + source, + requestId, + } + if (existingIndex >= 0) { + updateMessage(conversationId, existingIndex, messagePayload) + } else { + addMessage(conversationId, messagePayload) + } + attachCreatedMessageToIteration(conv, conversationId, messageId, requestId) + updateConversation(conversationId, { status: 'active', hasChat: true }) + } + + function enqueueStructuredOutputChunk( + conversationId: string, + messageIndex: number | undefined, + chunk: Chunk, + ): void { + const conv = state.conversations[conversationId] + if (conv?.type === 'client' && messageIndex !== undefined) { + queueMessageChunk(conversationId, messageIndex, chunk) + } else if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + addChunk(conversationId, chunk) + } + } + + function applyApprovalRequestedToConversation( + conversationId: string, + payload: ToolsApprovalRequestedEvent, + source: 'client' | 'server', + ): void { + const { + messageId, + toolCallId, + toolName, + input, + approvalId, + timestamp, + clientId, + } = payload + ensureChatConversation(conversationId, clientId, source) + let resolvedMessageId = messageId + const location = findToolCallLocation(conversationId, { toolCallId }) + if (location) { + updateToolCall( + conversationId, + location.messageIndex, + location.toolCallIndex, + { + approvalRequired: true, + approvalId, + state: 'approval-requested', + }, + ) + resolvedMessageId = + state.conversations[conversationId]?.messages[location.messageIndex] + ?.id ?? messageId + } else { + resolvedMessageId = messageId || `approval-message-${toolCallId}` + addMessage( + conversationId, + createClientToolCallMessage({ + messageId: resolvedMessageId, + toolCallId, + toolName, + arguments: stringifyToolArguments(input), + state: 'approval-requested', + timestamp, + source: chatConversationKind(conversationId, clientId, source), + approvalRequired: true, + approvalId, + }), + ) + } + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'approval', + ...(resolvedMessageId ? { messageId: resolvedMessageId } : {}), + toolCallId, + toolName, + approvalId, + input, + timestamp, + chunkCount: 1, + } + + if (state.conversations[conversationId]?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + addChunk(conversationId, chunk) + } + } + + function applyApprovalRequestedFallback( + payload: ToolsApprovalRequestedEvent, + ): void { + const { + clientId, + threadId, + messageId, + toolCallId, + toolName, + input, + approvalId, + timestamp, + } = payload + const fallbackConversationId = clientId || threadId + if (!fallbackConversationId) return + + getOrCreateConversation( + fallbackConversationId, + clientId ? 'client' : 'server', + clientId + ? `Client Chat (${fallbackConversationId.substring(0, 8)})` + : `Server Chat (${fallbackConversationId.substring(0, 8)})`, + ) + const resolvedMessageId = messageId || `approval-message-${toolCallId}` + addMessage( + fallbackConversationId, + createClientToolCallMessage({ + messageId: resolvedMessageId, + toolCallId, + toolName, + arguments: stringifyToolArguments(input), + state: 'approval-requested', + timestamp, + source: clientId ? 'client' : 'server', + approvalRequired: true, + approvalId, + }), + ) + addChunkToMessage(fallbackConversationId, { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'approval', + messageId: resolvedMessageId, + toolCallId, + toolName, + approvalId, + input, + timestamp, + chunkCount: 1, + }) + } + // Register all event listeners on mount onMount(() => { const cleanupFns: Array<() => void> = [] @@ -1351,208 +1761,7 @@ export const AIProvider: ParentComponent = (props) => { cleanupFns.push( aiEventClient.on('text:message:created', (e) => { - const { - clientId, - streamId, - messageId, - role, - content, - timestamp, - requestId, - } = e.payload - const conversationId = - clientId || - (streamId ? streamToConversation.get(streamId) : undefined) - - if (!conversationId) return - if (clientId && streamId) { - streamToConversation.set(streamId, clientId) - } - if (role === 'tool' || role === 'system') return - - const source = normalizeMessageSource( - e.payload.source, - clientId ? 'client' : 'server', - ) - const conversationType = - clientId && source !== 'server' ? 'client' : 'server' - - if (!state.conversations[conversationId]) { - getOrCreateConversation( - conversationId, - conversationType, - conversationType === 'client' - ? `Client Chat (${conversationId.substring(0, 8)})` - : `Server Chat (${conversationId.substring(0, 8)})`, - ) - } - - const conv = state.conversations[conversationId] - if (!conv) return - - const existingIndex = conv.messages.findIndex( - (message) => message.id === messageId, - ) - - const parts = - e.payload.parts - ?.map((part): MessagePart | null => { - if (part.type === 'text') { - return { type: 'text', content: part.content } - } - if (part.type === 'tool-call') { - return { - type: 'tool-call', - toolCallId: part.id, - toolName: part.name, - arguments: part.arguments, - state: part.state, - output: part.output, - approval: part.approval, - content: part.approval - ? JSON.stringify(part.approval) - : undefined, - } - } - if (part.type === 'tool-result') { - return { - type: 'tool-result', - toolCallId: part.toolCallId, - content: part.content, - state: part.state, - error: part.error, - } - } - if (part.type === 'thinking') { - return { - type: 'thinking', - content: part.content, - } - } - if (part.type === 'structured-output') { - return { - type: 'structured-output', - status: part.status, - raw: part.raw, - partial: part.partial, - data: part.data, - reasoning: part.reasoning, - errorMessage: part.errorMessage, - } - } - // Handle multimodal parts (image, audio, video) - // These have a source property instead of content - if ( - part.type === 'image' || - part.type === 'audio' || - part.type === 'video' - ) { - return { - type: part.type, - source: part.source, - metadata: part.metadata, - } - } - // Fallback for any unknown part types - skip them - return null - }) - .filter((part): part is MessagePart => part !== null) ?? [] - - const toolCallsFromPayload = e.payload.toolCalls?.map((toolCall) => ({ - id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments, - state: 'input-complete', - })) - const toolCallsFromParts = parts - .filter((part) => part.type === 'tool-call') - .map((part) => ({ - id: part.toolCallId ?? `${messageId}:${part.toolName ?? 'tool'}`, - name: part.toolName ?? 'tool', - arguments: part.arguments ?? stringifyToolArguments(part.output), - state: part.state ?? 'input-complete', - ...(part.output !== undefined ? { result: part.output } : {}), - ...(part.approval?.needsApproval !== undefined - ? { approvalRequired: part.approval.needsApproval } - : {}), - ...(part.approval?.id ? { approvalId: part.approval.id } : {}), - ...(part.approval?.approved !== undefined - ? { approvalApproved: part.approval.approved } - : {}), - })) - const toolCalls = - toolCallsFromPayload && toolCallsFromPayload.length > 0 - ? toolCallsFromPayload - : toolCallsFromParts.length > 0 - ? toolCallsFromParts - : undefined - - if (role === 'user' && conv.type === 'client' && source === 'server') { - return - } - - if ( - shouldSkipClientAssistantPlaceholder({ - role, - source, - content, - toolCalls, - parts, - }) - ) { - return - } - - const messagePayload: Message = { - id: messageId, - role, - content, - timestamp, - parts, - toolCalls, - source, - requestId, - } - - if (existingIndex >= 0) { - updateMessage(conversationId, existingIndex, messagePayload) - } else { - addMessage(conversationId, messagePayload) - } - - // Track messageId in the correct iteration (scoped by requestId) - if (conv.iterations.length > 0) { - let iterIndex = -1 - if (requestId) { - // Find the latest iteration for this specific request - for (let i = conv.iterations.length - 1; i >= 0; i--) { - if (conv.iterations[i]?.requestId === requestId) { - iterIndex = i - break - } - } - } else { - // Fallback: use latest iteration - iterIndex = conv.iterations.length - 1 - } - if (iterIndex >= 0) { - const iter = conv.iterations[iterIndex] - if (iter && !iter.messageIds.includes(messageId)) { - setState( - 'conversations', - conversationId, - 'iterations', - iterIndex, - 'messageIds', - produce((arr: Array) => { - arr.push(messageId) - }), - ) - } - } - } - - updateConversation(conversationId, { status: 'active', hasChat: true }) + handleTextMessageCreated(e.payload) }), ) @@ -1997,41 +2206,18 @@ export const AIProvider: ParentComponent = (props) => { streamId, messageId, }) + const fields = structuredOutputFields(payload) for (const conversationId of conversationIds) { - if (!state.conversations[conversationId]) { - getOrCreateConversation( - conversationId, - conversationId === clientId && source === 'client' - ? 'client' - : 'server', - conversationId === clientId && source === 'client' - ? `Client Chat (${conversationId.substring(0, 8)})` - : `Server Chat (${conversationId.substring(0, 8)})`, - ) - } - + ensureChatConversation(conversationId, clientId, source) attachRunToConversation(conversationId, runId) const messageIndex = upsertStructuredOutputPart(conversationId, { messageId, timestamp, - source: - conversationId === clientId && source === 'client' - ? 'client' - : 'server', + source: chatConversationKind(conversationId, clientId, source), ...(requestId ? { requestId } : {}), status: payload.status, - raw: payload.raw ?? '', - ...(payload.partial !== undefined - ? { partial: payload.partial } - : {}), - ...(payload.data !== undefined ? { data: payload.data } : {}), - ...(payload.reasoning !== undefined - ? { reasoning: payload.reasoning } - : {}), - ...(payload.errorMessage !== undefined - ? { errorMessage: payload.errorMessage } - : {}), + ...fields, }) const chunk: Chunk = { @@ -2041,29 +2227,11 @@ export const AIProvider: ParentComponent = (props) => { timestamp, chunkCount: 1, structuredStatus: payload.status, - raw: payload.raw ?? '', - ...(payload.partial !== undefined - ? { partial: payload.partial } - : {}), - ...(payload.data !== undefined ? { data: payload.data } : {}), - ...(payload.reasoning !== undefined - ? { reasoning: payload.reasoning } - : {}), - ...(payload.errorMessage !== undefined - ? { errorMessage: payload.errorMessage } - : {}), + ...fields, ...(payload.delta !== undefined ? { delta: payload.delta } : {}), } - const conv = state.conversations[conversationId] - if (conv?.type === 'client' && messageIndex !== undefined) { - queueMessageChunk(conversationId, messageIndex, chunk) - } else if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - addChunk(conversationId, chunk) - } - + enqueueStructuredOutputChunk(conversationId, messageIndex, chunk) attachMessageToLatestIteration(conversationId, messageId, requestId) updateConversation(conversationId, { status: payload.status === 'error' ? 'error' : 'active', @@ -2460,18 +2628,8 @@ export const AIProvider: ParentComponent = (props) => { cleanupFns.push( aiEventClient.on('tools:approval:requested', (e) => { - const { - streamId, - messageId, - toolCallId, - toolName, - input, - approvalId, - timestamp, - clientId, - threadId, - } = e.payload - + const { streamId, clientId, threadId, toolCallId, approvalId } = + e.payload const source = normalizeMessageSource( e.payload.source, clientId ? 'client' : 'server', @@ -2489,113 +2647,15 @@ export const AIProvider: ParentComponent = (props) => { }) for (const conversationId of conversationIds) { - if (!state.conversations[conversationId]) { - getOrCreateConversation( - conversationId, - conversationId === clientId && source === 'client' - ? 'client' - : 'server', - conversationId === clientId && source === 'client' - ? `Client Chat (${conversationId.substring(0, 8)})` - : `Server Chat (${conversationId.substring(0, 8)})`, - ) - } - - let resolvedMessageId = messageId - const location = findToolCallLocation(conversationId, { toolCallId }) - if (location) { - updateToolCall( - conversationId, - location.messageIndex, - location.toolCallIndex, - { - approvalRequired: true, - approvalId, - state: 'approval-requested', - }, - ) - resolvedMessageId = - state.conversations[conversationId]?.messages[ - location.messageIndex - ]?.id ?? messageId - } else { - resolvedMessageId = messageId || `approval-message-${toolCallId}` - addMessage( - conversationId, - createClientToolCallMessage({ - messageId: resolvedMessageId, - toolCallId, - toolName, - arguments: stringifyToolArguments(input), - state: 'approval-requested', - timestamp, - source: - conversationId === clientId && source === 'client' - ? 'client' - : 'server', - approvalRequired: true, - approvalId, - }), - ) - } - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'approval', - ...(resolvedMessageId ? { messageId: resolvedMessageId } : {}), - toolCallId, - toolName, - approvalId, - input, - timestamp, - chunkCount: 1, - } - - if (state.conversations[conversationId]?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - addChunk(conversationId, chunk) - } + applyApprovalRequestedToConversation( + conversationId, + e.payload, + source, + ) } if (conversationIds.length === 0) { - const fallbackConversationId = clientId || threadId - if (!fallbackConversationId) return - - getOrCreateConversation( - fallbackConversationId, - clientId ? 'client' : 'server', - clientId - ? `Client Chat (${fallbackConversationId.substring(0, 8)})` - : `Server Chat (${fallbackConversationId.substring(0, 8)})`, - ) - const resolvedMessageId = - messageId || `approval-message-${toolCallId}` - addMessage( - fallbackConversationId, - createClientToolCallMessage({ - messageId: resolvedMessageId, - toolCallId, - toolName, - arguments: stringifyToolArguments(input), - state: 'approval-requested', - timestamp, - source: clientId ? 'client' : 'server', - approvalRequired: true, - approvalId, - }), - ) - addChunkToMessage(fallbackConversationId, { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'approval', - messageId: resolvedMessageId, - toolCallId, - toolName, - approvalId, - input, - timestamp, - chunkCount: 1, - }) + applyApprovalRequestedFallback(e.payload) } }), ) diff --git a/packages/ai-devtools/src/store/hook-registry.ts b/packages/ai-devtools/src/store/hook-registry.ts index be3f7ef118..30ef6f5a41 100644 --- a/packages/ai-devtools/src/store/hook-registry.ts +++ b/packages/ai-devtools/src/store/hook-registry.ts @@ -189,46 +189,59 @@ interface HookUpsertEvent extends Partial< timestamp: number } +type EventDedupeFields = Partial & { + runtimeId?: string + timestamp?: number +} + +function syntheticEventDedupeKey( + eventName: string, + event: EventDedupeFields, +): string { + return [ + eventName, + event.source ?? 'unknown', + event.visibility ?? 'unknown', + event.runtimeId ?? 'no-runtime', + event.hookId ?? event.clientId ?? 'no-hook', + event.threadId ?? 'no-thread', + event.runId ?? 'no-run', + event.messageId ?? 'no-message', + event.toolCallId ?? 'no-tool-call', + event.timestamp ?? 'no-time', + ].join(':') +} + +function eventHasNoDedupeIdentity(event: EventDedupeFields): boolean { + return ( + !event.source && + !event.visibility && + !event.runtimeId && + !event.hookId && + !event.clientId && + !event.threadId && + !event.runId && + !event.messageId && + !event.toolCallId && + !event.timestamp + ) +} + export function markEventSeen( state: HookRegistryState, eventName: string, - event: Partial & { - runtimeId?: string - timestamp?: number - }, + event: EventDedupeFields, ): boolean { let key: string if (event.eventId) { key = event.eventId } else { - key = [ - eventName, - event.source ?? 'unknown', - event.visibility ?? 'unknown', - event.runtimeId ?? 'no-runtime', - event.hookId ?? event.clientId ?? 'no-hook', - event.threadId ?? 'no-thread', - event.runId ?? 'no-run', - event.messageId ?? 'no-message', - event.toolCallId ?? 'no-tool-call', - event.timestamp ?? 'no-time', - ].join(':') + key = syntheticEventDedupeKey(eventName, event) // If every identifying field fell back to its literal sentinel the synthesised // key is just `eventName:unknown:unknown:no-runtime:...` — useful for very // little. Warn so it's obvious in the console why deduplication may be // collapsing distinct events together. - if ( - !event.source && - !event.visibility && - !event.runtimeId && - !event.hookId && - !event.clientId && - !event.threadId && - !event.runId && - !event.messageId && - !event.toolCallId && - !event.timestamp - ) { + if (eventHasNoDedupeIdentity(event)) { console.warn( `[ai-devtools] dedupe key for "${eventName}" has no identifying fields; events may collide.`, ) @@ -249,6 +262,175 @@ export function markEventSeen( return true } +type HookEventHandler = ( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +) => void + +function applyRegisteredHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +): void { + const registered = event as HookRegisteredEvent + delete state.unregisteredHookIds[registered.hookId] + upsertHook(state, registered) + attachEventToHook(state, registered.hookId, timelineEvent.id) +} + +function applyUpdatedHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +): void { + const updated = event as HookUpdatedEvent + if (state.unregisteredHookIds[updated.hookId]) { + return + } + if (isStaleHookInstanceEvent(state, updated.hookId, updated)) { + return + } + upsertHook(state, updated) + attachEventToHook(state, updated.hookId, timelineEvent.id) +} + +function applyUnregisteredHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, +): void { + const unregistered = event as HookUnregisteredEvent + const existing = state.hooks[unregistered.hookId] + if ( + existing?.clientId && + unregistered.clientId && + existing.clientId !== unregistered.clientId + ) { + return + } + if ( + existing?.correlationId && + unregistered.correlationId && + existing.correlationId !== unregistered.correlationId + ) { + return + } + if (existing && existing.registeredAt > unregistered.timestamp) { + return + } + state.unregisteredHookIds[unregistered.hookId] = true + removeHookRecord(state, unregistered.hookId) +} + +function applyStateSnapshotHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +): void { + const snapshot = event as HookStateSnapshotEvent + if (state.unregisteredHookIds[snapshot.hookId]) { + return + } + if (isStaleHookInstanceEvent(state, snapshot.hookId, snapshot)) { + return + } + upsertHook(state, { + ...snapshot, + lifecycle: inferLifecycleFromSnapshot(snapshot.state), + }) + const hook = state.hooks[snapshot.hookId] + if (hook) { + hook.state = snapshot.state + hook.updatedAt = snapshot.timestamp + } + attachEventToHook(state, snapshot.hookId, timelineEvent.id) + // Generation hooks ship their run history inside the snapshot. When + // devtools is opened after a run has already completed, the run + // lifecycle events fired before mount are lost, so backfill the + // global runs map and the hook's runIds from the snapshot itself. + syncRunsFromSnapshot(state, snapshot, timelineEvent.id) +} + +function applyToolsRegisteredHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +): void { + const toolsEvent = event as ToolsRegisteredEvent + if (state.unregisteredHookIds[toolsEvent.hookId]) { + return + } + if (isStaleHookInstanceEvent(state, toolsEvent.hookId, toolsEvent)) { + return + } + if (!toolsEvent.hookName) { + console.warn( + `[ai-devtools] tools:registered event for hook "${toolsEvent.hookId}" had no hookName; displaying raw hookId in the UI.`, + ) + } + upsertHook(state, { + ...toolsEvent, + hookName: toolsEvent.hookName ?? toolsEvent.hookId, + lifecycle: 'active', + }) + const hook = state.hooks[toolsEvent.hookId] + if (hook) { + hook.tools = toolsEvent.tools + hook.updatedAt = toolsEvent.timestamp + } + attachEventToHook(state, toolsEvent.hookId, timelineEvent.id) +} + +function applyRunLifecycleHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +): void { + const runEvent = event as RunLifecycleEvent + if (runEvent.hookId && state.unregisteredHookIds[runEvent.hookId]) { + return + } + if ( + runEvent.hookId && + isStaleHookInstanceEvent(state, runEvent.hookId, runEvent) + ) { + return + } + upsertRun(state, runEvent, timelineEvent.id) + if (runEvent.hookId) { + upsertUnknownHook(state, runEvent.hookId, runEvent) + attachRunToHook(state, runEvent.hookId, runEvent.runId) + attachActivityRunToHook(state, runEvent.hookId, runEvent.runId) + attachEventToHook(state, runEvent.hookId, timelineEvent.id) + } +} + +function applyToolFixtureHookEvent( + state: HookRegistryState, + event: RuntimeScopedHookEvent, + timelineEvent: TimelineEvent, +): void { + const fixtureEvent = event as DevtoolsToolFixtureApplyEvent + if (fixtureEvent.hookId) { + attachEventToHook(state, fixtureEvent.hookId, timelineEvent.id) + } +} + +const hookEventHandlers: Record = { + 'hook:registered': applyRegisteredHookEvent, + 'hook:updated': applyUpdatedHookEvent, + 'hook:unregistered': applyUnregisteredHookEvent, + 'hook:state-snapshot': applyStateSnapshotHookEvent, + 'tools:registered': applyToolsRegisteredHookEvent, + 'run:created': applyRunLifecycleHookEvent, + 'run:started': applyRunLifecycleHookEvent, + 'run:updated': applyRunLifecycleHookEvent, + 'run:completed': applyRunLifecycleHookEvent, + 'run:errored': applyRunLifecycleHookEvent, + 'run:cancelled': applyRunLifecycleHookEvent, + 'devtools:tool-fixture:apply': applyToolFixtureHookEvent, +} + export function applyHookEvent( state: HookRegistryState, eventName: string, @@ -265,134 +447,8 @@ export function applyHookEvent( const timelineEvent = createTimelineEvent(eventName, event) state.events[timelineEvent.id] = timelineEvent - switch (eventName) { - case 'hook:registered': { - const registered = event as HookRegisteredEvent - delete state.unregisteredHookIds[registered.hookId] - upsertHook(state, registered) - attachEventToHook(state, registered.hookId, timelineEvent.id) - break - } - case 'hook:updated': { - const updated = event as HookUpdatedEvent - if (state.unregisteredHookIds[updated.hookId]) { - break - } - if (isStaleHookInstanceEvent(state, updated.hookId, updated)) { - break - } - upsertHook(state, updated) - attachEventToHook(state, updated.hookId, timelineEvent.id) - break - } - case 'hook:unregistered': { - const unregistered = event as HookUnregisteredEvent - const existing = state.hooks[unregistered.hookId] - if ( - existing?.clientId && - unregistered.clientId && - existing.clientId !== unregistered.clientId - ) { - break - } - if ( - existing?.correlationId && - unregistered.correlationId && - existing.correlationId !== unregistered.correlationId - ) { - break - } - if (existing && existing.registeredAt > unregistered.timestamp) { - break - } - state.unregisteredHookIds[unregistered.hookId] = true - removeHookRecord(state, unregistered.hookId) - break - } - case 'hook:state-snapshot': { - const snapshot = event as HookStateSnapshotEvent - if (state.unregisteredHookIds[snapshot.hookId]) { - break - } - if (isStaleHookInstanceEvent(state, snapshot.hookId, snapshot)) { - break - } - upsertHook(state, { - ...snapshot, - lifecycle: inferLifecycleFromSnapshot(snapshot.state), - }) - const hook = state.hooks[snapshot.hookId] - if (hook) { - hook.state = snapshot.state - hook.updatedAt = snapshot.timestamp - } - attachEventToHook(state, snapshot.hookId, timelineEvent.id) - // Generation hooks ship their run history inside the snapshot. When - // devtools is opened after a run has already completed, the run - // lifecycle events fired before mount are lost, so backfill the - // global runs map and the hook's runIds from the snapshot itself. - syncRunsFromSnapshot(state, snapshot, timelineEvent.id) - break - } - case 'tools:registered': { - const toolsEvent = event as ToolsRegisteredEvent - if (state.unregisteredHookIds[toolsEvent.hookId]) { - break - } - if (isStaleHookInstanceEvent(state, toolsEvent.hookId, toolsEvent)) { - break - } - if (!toolsEvent.hookName) { - console.warn( - `[ai-devtools] tools:registered event for hook "${toolsEvent.hookId}" had no hookName; displaying raw hookId in the UI.`, - ) - } - upsertHook(state, { - ...toolsEvent, - hookName: toolsEvent.hookName ?? toolsEvent.hookId, - lifecycle: 'active', - }) - const hook = state.hooks[toolsEvent.hookId] - if (hook) { - hook.tools = toolsEvent.tools - hook.updatedAt = toolsEvent.timestamp - } - attachEventToHook(state, toolsEvent.hookId, timelineEvent.id) - break - } - case 'run:created': - case 'run:started': - case 'run:updated': - case 'run:completed': - case 'run:errored': - case 'run:cancelled': { - const runEvent = event as RunLifecycleEvent - if (runEvent.hookId && state.unregisteredHookIds[runEvent.hookId]) { - break - } - if ( - runEvent.hookId && - isStaleHookInstanceEvent(state, runEvent.hookId, runEvent) - ) { - break - } - upsertRun(state, runEvent, timelineEvent.id) - if (runEvent.hookId) { - upsertUnknownHook(state, runEvent.hookId, runEvent) - attachRunToHook(state, runEvent.hookId, runEvent.runId) - attachActivityRunToHook(state, runEvent.hookId, runEvent.runId) - attachEventToHook(state, runEvent.hookId, timelineEvent.id) - } - break - } - case 'devtools:tool-fixture:apply': { - const fixtureEvent = event as DevtoolsToolFixtureApplyEvent - if (fixtureEvent.hookId) { - attachEventToHook(state, fixtureEvent.hookId, timelineEvent.id) - } - break - } - } + const apply = hookEventHandlers[eventName] + if (apply) apply(state, event, timelineEvent) if (state.activeHookId && !state.hooks[state.activeHookId]) { state.activeHookId = null diff --git a/packages/ai-durable-stream/src/durable-stream.ts b/packages/ai-durable-stream/src/durable-stream.ts index 22c42a65e4..60e8258b6f 100644 --- a/packages/ai-durable-stream/src/durable-stream.ts +++ b/packages/ai-durable-stream/src/durable-stream.ts @@ -319,6 +319,188 @@ async function* parseSseEvents( if (hasField) yield current } +async function* consumeSseWindow(args: { + body: ReadableStream + signal: AbortSignal | undefined + dataStartOffset: string + backendOffset: string + streamCursor: string | undefined + deliveredThroughSeq: number + previousResponseSeq: number + stopWhenUpToDate: boolean + observeSeq: (seq: number) => void +}): AsyncGenerator< + { offset: DurableStreamOffset; chunk: StreamChunk }, + { + dataAwaitingControl: boolean + sawControl: boolean + yieldedData: boolean + backendOffset: string + streamCursor: string | undefined + deliveredThroughSeq: number + done: boolean + } +> { + let dataAwaitingControl = false + let sawControl = false + let yieldedData = false + let { + dataStartOffset, + backendOffset, + streamCursor, + deliveredThroughSeq, + previousResponseSeq, + } = args + for await (const event of parseSseEvents(args.body, args.signal)) { + if (args.signal?.aborted) { + return { + dataAwaitingControl, + sawControl, + yieldedData, + backendOffset, + streamCursor, + deliveredThroughSeq, + done: true, + } + } + if (event.event === 'data') { + dataAwaitingControl = true + for (const record of parseDataRecords(event.data)) { + if (record.seq <= previousResponseSeq) { + throw new DurableStreamError( + 'data records must have strictly increasing sequences', + ) + } + previousResponseSeq = record.seq + args.observeSeq(record.seq) + if (record.seq <= deliveredThroughSeq) continue + deliveredThroughSeq = record.seq + yieldedData = true + yield { + offset: encodeCursor({ + v: 1, + backendOffset: dataStartOffset, + seq: record.seq, + }), + chunk: record.chunk, + } + } + continue + } + if (event.event === 'control') { + const control = parseControlFrame(event.data) + backendOffset = control.streamNextOffset + streamCursor = control.streamCursor + dataStartOffset = backendOffset + sawControl = true + dataAwaitingControl = false + if ( + control.streamClosed === true || + (args.stopWhenUpToDate && control.upToDate === true) + ) { + return { + dataAwaitingControl, + sawControl, + yieldedData, + backendOffset, + streamCursor, + deliveredThroughSeq, + done: true, + } + } + continue + } + throw new DurableStreamError( + `unexpected SSE event type: ${JSON.stringify(event.event)}`, + ) + } + return { + dataAwaitingControl, + sawControl, + yieldedData, + backendOffset, + streamCursor, + deliveredThroughSeq, + done: false, + } +} + +async function fetchReadWindow(args: { + streamUrl: string + backendOffset: string + streamCursor: string | undefined + fetchFn: typeof fetch + resolveHeaders: () => Promise + signal: AbortSignal | undefined +}): Promise { + const url = new URL(args.streamUrl) + url.searchParams.set('offset', args.backendOffset) + url.searchParams.set('live', 'sse') + if (args.streamCursor !== undefined) { + url.searchParams.set('cursor', args.streamCursor) + } + try { + return await args.fetchFn(url, { + method: 'GET', + headers: await args.resolveHeaders(), + signal: args.signal, + }) + } catch (error) { + if (args.signal?.aborted) return undefined + throw error + } +} + +function assertWindowAdvanced(state: { + dataAwaitingControl: boolean + sawControl: boolean + backendOffset: string + requestOffset: string + streamCursor: string | undefined + requestCursor: string | undefined +}): void { + if (state.dataAwaitingControl || !state.sawControl) { + throw new DurableStreamError( + 'read SSE window ended without a matching control event', + ) + } + if ( + state.backendOffset === state.requestOffset && + state.streamCursor === state.requestCursor + ) { + throw new DurableStreamError( + 'read SSE window ended without advancing offset or cursor', + ) + } +} + +function unwrapReadError(error: unknown): unknown { + if (error instanceof ResponseBodyReadFailure) return error.readError + return error +} + +function shouldRetryReadFailure( + error: unknown, + state: { + signal: AbortSignal | undefined + yieldedData: boolean + sawControl: boolean + backendOffset: string + requestOffset: string + streamCursor: string | undefined + requestCursor: string | undefined + }, +): 'abort' | 'retry' | 'throw' { + if (state.signal?.aborted) return 'abort' + if (!(error instanceof ResponseBodyReadFailure)) return 'throw' + const progressed = + state.yieldedData || + (state.sawControl && + (state.backendOffset !== state.requestOffset || + state.streamCursor !== state.requestCursor)) + return progressed ? 'retry' : 'throw' +} + function isStreamChunk(value: unknown): value is StreamChunk { return ( typeof value === 'object' && @@ -653,105 +835,59 @@ export function durableStream( } const requestOffset = backendOffset const requestCursor = streamCursor - const url = new URL(streamUrl) - url.searchParams.set('offset', backendOffset) - url.searchParams.set('live', 'sse') - if (streamCursor !== undefined) { - url.searchParams.set('cursor', streamCursor) - } - - let response: Response - try { - response = await fetchFn(url, { - method: 'GET', - headers: await resolveHeaders(), - signal, - }) - } catch (error) { - if (signal?.aborted) return - throw error - } + const response = await fetchReadWindow({ + streamUrl, + backendOffset, + streamCursor, + fetchFn, + resolveHeaders, + signal, + }) + if (!response) return if (!response.ok) throw httpFailure('read', response) if (!response.body) { throw new DurableStreamError('read response had no body') } - let dataStartOffset = backendOffset let sawControl = false let dataAwaitingControl = false let yieldedData = false - // Guards intra-response ordering: seqs must strictly increase across the - // whole response (including across data frames and control frames — seq - // is per-run, not per-window). Starts at 0 so a legitimate replay of - // already-delivered records still passes, then the dedup below drops - // them; the throw catches a genuinely malformed [seq 2, seq 1] or a - // duplicate seq that would otherwise be silently discarded. - let previousResponseSeq = 0 try { - for await (const event of parseSseEvents(response.body, signal)) { - if (signal?.aborted) return - if (event.event === 'data') { - dataAwaitingControl = true - for (const record of parseDataRecords(event.data)) { - if (record.seq <= previousResponseSeq) { - throw new DurableStreamError( - 'data records must have strictly increasing sequences', - ) - } - previousResponseSeq = record.seq - observeSeq(record.seq) - if (record.seq <= deliveredThroughSeq) continue - deliveredThroughSeq = record.seq - yieldedData = true - yield { - offset: encodeCursor({ - v: 1, - backendOffset: dataStartOffset, - seq: record.seq, - }), - chunk: record.chunk, - } - } - continue - } - if (event.event === 'control') { - const control = parseControlFrame(event.data) - backendOffset = control.streamNextOffset - streamCursor = control.streamCursor - dataStartOffset = backendOffset - sawControl = true - dataAwaitingControl = false - if (control.streamClosed === true) return - // A snapshot has now been handed everything the backend holds. - // Returning here abandons the rest of the SSE body, which the - // reader's `finally` cancels. - if (stopWhenUpToDate && control.upToDate === true) return - continue - } - throw new DurableStreamError( - `unexpected SSE event type: ${JSON.stringify(event.event)}`, - ) - } + const consumed = yield* consumeSseWindow({ + body: response.body, + signal, + dataStartOffset: backendOffset, + backendOffset, + streamCursor, + deliveredThroughSeq, + previousResponseSeq: 0, + stopWhenUpToDate, + observeSeq, + }) + dataAwaitingControl = consumed.dataAwaitingControl + sawControl = consumed.sawControl + yieldedData = consumed.yieldedData + backendOffset = consumed.backendOffset + streamCursor = consumed.streamCursor + deliveredThroughSeq = consumed.deliveredThroughSeq + if (consumed.done) return } catch (error) { - if (signal?.aborted) return - if (error instanceof ResponseBodyReadFailure) { - if ( - yieldedData || - (sawControl && - (backendOffset !== requestOffset || - streamCursor !== requestCursor)) - ) { - // Made progress before the body failed — retry from the last valid - // position, but cap consecutive failures and throttle so a - // persistently failing backend surfaces the error, not a hot loop. - consecutiveReadFailures += 1 - if (consecutiveReadFailures > maxReadFailures) throw error.readError - await abortableDelay(readRetryDelayMs, signal) - continue - } - throw error.readError - } - throw error + const retry = shouldRetryReadFailure(error, { + signal, + yieldedData, + sawControl, + backendOffset, + requestOffset, + streamCursor, + requestCursor, + }) + if (retry === 'abort') return + if (retry === 'throw') throw unwrapReadError(error) + consecutiveReadFailures += 1 + if (consecutiveReadFailures > maxReadFailures) + throw unwrapReadError(error) + await abortableDelay(readRetryDelayMs, signal) + continue } // A window read to completion (no body failure) clears the streak; only @@ -759,16 +895,14 @@ export function durableStream( consecutiveReadFailures = 0 if (signal?.aborted) return - if (dataAwaitingControl || !sawControl) { - throw new DurableStreamError( - 'read SSE window ended without a matching control event', - ) - } - if (backendOffset === requestOffset && streamCursor === requestCursor) { - throw new DurableStreamError( - 'read SSE window ended without advancing offset or cursor', - ) - } + assertWindowAdvanced({ + dataAwaitingControl, + sawControl, + backendOffset, + requestOffset, + streamCursor, + requestCursor, + }) } } diff --git a/packages/ai-elevenlabs/src/adapters/transcription.ts b/packages/ai-elevenlabs/src/adapters/transcription.ts index fa459b9928..40cd784990 100644 --- a/packages/ai-elevenlabs/src/adapters/transcription.ts +++ b/packages/ai-elevenlabs/src/adapters/transcription.ts @@ -98,56 +98,9 @@ export class ElevenLabsTranscriptionAdapter< { provider: 'elevenlabs', model: this.model }, ) try { - const modelOpts = options.modelOptions ?? {} - const audioInput = normalizeAudioInput(options.audio) - - const response = await this.client.speechToText.convert({ - modelId: this.model, - ...(audioInput.kind === 'file' - ? { file: audioInput.value } - : { cloudStorageUrl: audioInput.value }), - ...(options.language ? { languageCode: options.language } : {}), - ...(modelOpts.tagAudioEvents != null - ? { tagAudioEvents: modelOpts.tagAudioEvents } - : {}), - ...(modelOpts.numSpeakers != null - ? { numSpeakers: modelOpts.numSpeakers } - : {}), - ...(modelOpts.timestampsGranularity - ? { timestampsGranularity: modelOpts.timestampsGranularity } - : {}), - ...(modelOpts.diarize != null ? { diarize: modelOpts.diarize } : {}), - ...(modelOpts.diarizationThreshold != null - ? { diarizationThreshold: modelOpts.diarizationThreshold } - : {}), - ...(modelOpts.detectSpeakerRoles != null - ? { detectSpeakerRoles: modelOpts.detectSpeakerRoles } - : {}), - ...(modelOpts.keyterms ? { keyterms: modelOpts.keyterms } : {}), - ...(modelOpts.entityDetection - ? { entityDetection: modelOpts.entityDetection } - : {}), - ...(modelOpts.entityRedaction - ? { entityRedaction: modelOpts.entityRedaction } - : {}), - ...(modelOpts.entityRedactionMode - ? { entityRedactionMode: modelOpts.entityRedactionMode } - : {}), - ...(modelOpts.noVerbatim != null - ? { noVerbatim: modelOpts.noVerbatim } - : {}), - ...(modelOpts.temperature != null - ? { temperature: modelOpts.temperature } - : {}), - ...(modelOpts.seed != null ? { seed: modelOpts.seed } : {}), - ...(modelOpts.enableLogging != null - ? { enableLogging: modelOpts.enableLogging } - : {}), - ...(modelOpts.useMultiChannel != null - ? { useMultiChannel: modelOpts.useMultiChannel } - : {}), - ...(modelOpts.fileFormat ? { fileFormat: modelOpts.fileFormat } : {}), - } as Parameters[0]) + const response = await this.client.speechToText.convert( + toSpeechToTextConvertParams(this.model, options), + ) return this.transformResponse(response) } catch (error) { @@ -234,6 +187,48 @@ type NormalizedAudio = | { kind: 'file'; value: Blob } | { kind: 'url'; value: string } +function assignIfDefined( + key: K, + value: V | null | undefined, +): Partial> { + if (value == null) return {} + return { [key]: value } as Partial> +} + +function toSpeechToTextConvertParams( + modelId: string, + options: TranscriptionOptions, +): Parameters[0] { + const modelOpts = options.modelOptions ?? {} + const audioInput = normalizeAudioInput(options.audio) + return { + modelId, + ...(audioInput.kind === 'file' + ? { file: audioInput.value } + : { cloudStorageUrl: audioInput.value }), + ...assignIfDefined('languageCode', options.language), + ...assignIfDefined('tagAudioEvents', modelOpts.tagAudioEvents), + ...assignIfDefined('numSpeakers', modelOpts.numSpeakers), + ...assignIfDefined( + 'timestampsGranularity', + modelOpts.timestampsGranularity, + ), + ...assignIfDefined('diarize', modelOpts.diarize), + ...assignIfDefined('diarizationThreshold', modelOpts.diarizationThreshold), + ...assignIfDefined('detectSpeakerRoles', modelOpts.detectSpeakerRoles), + ...assignIfDefined('keyterms', modelOpts.keyterms), + ...assignIfDefined('entityDetection', modelOpts.entityDetection), + ...assignIfDefined('entityRedaction', modelOpts.entityRedaction), + ...assignIfDefined('entityRedactionMode', modelOpts.entityRedactionMode), + ...assignIfDefined('noVerbatim', modelOpts.noVerbatim), + ...assignIfDefined('temperature', modelOpts.temperature), + ...assignIfDefined('seed', modelOpts.seed), + ...assignIfDefined('enableLogging', modelOpts.enableLogging), + ...assignIfDefined('useMultiChannel', modelOpts.useMultiChannel), + ...assignIfDefined('fileFormat', modelOpts.fileFormat), + } as Parameters[0] +} + function normalizeAudioInput( audio: TranscriptionOptions['audio'], ): NormalizedAudio { diff --git a/packages/ai-event-client/src/devtools-middleware.ts b/packages/ai-event-client/src/devtools-middleware.ts index ec626af77a..a4914dfbbd 100644 --- a/packages/ai-event-client/src/devtools-middleware.ts +++ b/packages/ai-event-client/src/devtools-middleware.ts @@ -321,6 +321,121 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { let iterationStartTime = 0 const activeToolCalls = new Map() + type ChunkBase = ReturnType + const chunkHandlers: Record< + string, + (chunk: DevtoolsKnownChunk, base: ChunkBase) => void + > = { + TEXT_MESSAGE_CONTENT: (chunk, base) => { + localAccumulatedContent += chunk.delta + safeEmit('text:chunk:content', { + ...base, + messageId: localMessageId || undefined, + content: localAccumulatedContent, + delta: chunk.delta, + timestamp: Date.now(), + }) + }, + TOOL_CALL_START: (chunk, base) => { + const toolIndex = chunk.index ?? 0 + const toolName = chunk.toolCallName + activeToolCalls.set(chunk.toolCallId, { + toolName, + index: toolIndex, + }) + safeEmit('text:chunk:tool-call', { + ...base, + messageId: localMessageId || undefined, + toolCallId: chunk.toolCallId, + toolName, + index: toolIndex, + arguments: '', + timestamp: Date.now(), + }) + }, + TOOL_CALL_ARGS: (chunk, base) => { + const active = activeToolCalls.get(chunk.toolCallId) + safeEmit('text:chunk:tool-call', { + ...base, + messageId: localMessageId || undefined, + toolCallId: chunk.toolCallId, + toolName: active?.toolName ?? '', + index: active?.index ?? 0, + arguments: chunk.delta, + timestamp: Date.now(), + }) + }, + TOOL_CALL_END: (chunk) => { + activeToolCalls.delete(chunk.toolCallId) + }, + TOOL_CALL_RESULT: (chunk, base) => { + // Server-executed tool results arrive on the spec-compliant + // TOOL_CALL_RESULT event (the adapter's TOOL_CALL_END carries only + // the parsed input). Surface them to devtools from here so results + // still show up now that the post-execution END is no longer + // re-emitted (#519). + safeEmit('text:chunk:tool-result', { + ...base, + messageId: localMessageId || undefined, + toolCallId: chunk.toolCallId, + result: chunk.content || '', + timestamp: Date.now(), + }) + }, + RUN_FINISHED: (chunk, base) => emitRunFinished(chunk, base), + RUN_ERROR: (chunk, base) => { + const errorMessage = + chunk.message ?? + `[ai-devtools] RUN_ERROR chunk had no message; raw chunk: ${JSON.stringify(chunk)}` + safeEmit('text:chunk:error', { + ...base, + messageId: localMessageId || undefined, + error: errorMessage, + timestamp: Date.now(), + }) + }, + REASONING_MESSAGE_CONTENT: (chunk, base) => { + localAccumulatedThinking += chunk.delta + safeEmit('text:chunk:thinking', { + ...base, + messageId: localMessageId || undefined, + content: localAccumulatedThinking, + delta: chunk.delta, + timestamp: Date.now(), + }) + }, + } + + function emitRunFinished(chunk: DevtoolsRunFinishedChunk, base: ChunkBase) { + const rawUsage = chunk.usage + const usage = + rawUsage != null && + typeof rawUsage === 'object' && + !Array.isArray(rawUsage) && + 'promptTokens' in rawUsage + ? rawUsage + : fromSpecTokenUsage( + Array.isArray(rawUsage) ? rawUsage : undefined, + chunkTanstack(chunk)?.usage, + ) + safeEmit('text:chunk:done', { + ...base, + messageId: localMessageId || undefined, + finishReason: + chunk.finishReason ?? chunkTanstack(chunk)?.finishReason ?? null, + usage, + timestamp: Date.now(), + }) + if (usage) { + safeEmit('text:usage', { + ...base, + messageId: localMessageId || undefined, + usage, + timestamp: Date.now(), + }) + } + } + return { name: 'devtools', @@ -410,125 +525,8 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { if (!isKnownChunk(rawChunk)) return const chunk = rawChunk const base = buildEventContext(ctx) - - switch (chunk.type) { - case 'TEXT_MESSAGE_CONTENT': { - localAccumulatedContent += chunk.delta - safeEmit('text:chunk:content', { - ...base, - messageId: localMessageId || undefined, - content: localAccumulatedContent, - delta: chunk.delta, - timestamp: Date.now(), - }) - break - } - case 'TOOL_CALL_START': { - const toolIndex = chunk.index ?? 0 - const toolName = chunk.toolCallName - activeToolCalls.set(chunk.toolCallId, { - toolName, - index: toolIndex, - }) - safeEmit('text:chunk:tool-call', { - ...base, - messageId: localMessageId || undefined, - toolCallId: chunk.toolCallId, - toolName, - index: toolIndex, - arguments: '', - timestamp: Date.now(), - }) - break - } - case 'TOOL_CALL_ARGS': { - const active = activeToolCalls.get(chunk.toolCallId) - safeEmit('text:chunk:tool-call', { - ...base, - messageId: localMessageId || undefined, - toolCallId: chunk.toolCallId, - toolName: active?.toolName ?? '', - index: active?.index ?? 0, - arguments: chunk.delta, - timestamp: Date.now(), - }) - break - } - case 'TOOL_CALL_END': { - activeToolCalls.delete(chunk.toolCallId) - break - } - case 'TOOL_CALL_RESULT': { - // Server-executed tool results arrive on the spec-compliant - // TOOL_CALL_RESULT event (the adapter's TOOL_CALL_END carries only - // the parsed input). Surface them to devtools from here so results - // still show up now that the post-execution END is no longer - // re-emitted (#519). - safeEmit('text:chunk:tool-result', { - ...base, - messageId: localMessageId || undefined, - toolCallId: chunk.toolCallId, - result: chunk.content || '', - timestamp: Date.now(), - }) - break - } - case 'RUN_FINISHED': { - const rawUsage = chunk.usage - const usage = - rawUsage != null && - typeof rawUsage === 'object' && - !Array.isArray(rawUsage) && - 'promptTokens' in rawUsage - ? rawUsage - : fromSpecTokenUsage( - Array.isArray(rawUsage) ? rawUsage : undefined, - chunkTanstack(chunk)?.usage, - ) - safeEmit('text:chunk:done', { - ...base, - messageId: localMessageId || undefined, - finishReason: - chunk.finishReason ?? chunkTanstack(chunk)?.finishReason ?? null, - usage, - timestamp: Date.now(), - }) - if (usage) { - safeEmit('text:usage', { - ...base, - messageId: localMessageId || undefined, - usage, - timestamp: Date.now(), - }) - } - break - } - case 'RUN_ERROR': { - const errorMessage = - chunk.message ?? - `[ai-devtools] RUN_ERROR chunk had no message; raw chunk: ${JSON.stringify(chunk)}` - safeEmit('text:chunk:error', { - ...base, - messageId: localMessageId || undefined, - error: errorMessage, - timestamp: Date.now(), - }) - break - } - case 'REASONING_MESSAGE_CONTENT': { - localAccumulatedThinking += chunk.delta - safeEmit('text:chunk:thinking', { - ...base, - messageId: localMessageId || undefined, - content: localAccumulatedThinking, - delta: chunk.delta, - timestamp: Date.now(), - }) - break - } - } - - // Return void — observation only, pass through unchanged + const handler = chunkHandlers[chunk.type] + if (handler) handler(chunk, base) }, onToolPhaseComplete(ctx, info: DevtoolsToolPhaseCompleteInfo) { diff --git a/packages/ai-gemini/src/adapters/image.ts b/packages/ai-gemini/src/adapters/image.ts index 4c5629a3e2..e965dd58cd 100644 --- a/packages/ai-gemini/src/adapters/image.ts +++ b/packages/ai-gemini/src/adapters/image.ts @@ -42,6 +42,16 @@ import type { } from '@google/genai' import type { GeminiClientConfig } from '../utils/client' +function assignDefined( + target: T, + key: K, + value: T[K] | undefined, +): void { + if (value !== undefined) { + target[key] = value + } +} + /** * Configuration for Gemini image adapter */ @@ -329,68 +339,39 @@ export class GeminiImageAdapter< options: ImageGenerationOptions, ): GenerateImagesConfig { const { size, numberOfImages, modelOptions } = options - - // Build with conditional spreads — under exactOptionalPropertyTypes the - // vendor `GenerateImagesConfig` fields are `field?: T` (no `| undefined`), - // so we can only assign the property when we actually have a value. - const sizeAspectRatio = size ? sizeToAspectRatio(size) : undefined - - // Named picks, never a wholesale spread — the mirror image of the native - // path below. A native-only field (safetySettings, thinkingConfig, - // imageConfig, systemInstruction) belongs to GenerateContentConfig and is - // rejected by generateImages with 400 INVALID_ARGUMENT, so it must not be - // able to reach here even when the caller's `modelOptions` was typed - // against both shapes at once (e.g. an adapter inferred from a union of - // model names). - return { + const config: GenerateImagesConfig = { numberOfImages: numberOfImages ?? 1, - // Map size to aspect ratio if provided; modelOptions.aspectRatio, - // picked after it, overrides. - ...(sizeAspectRatio !== undefined && { aspectRatio: sizeAspectRatio }), - ...(modelOptions?.aspectRatio !== undefined && { - aspectRatio: modelOptions.aspectRatio, - }), - ...(modelOptions?.personGeneration !== undefined && { - personGeneration: modelOptions.personGeneration, - }), - ...(modelOptions?.safetyFilterLevel !== undefined && { - safetyFilterLevel: modelOptions.safetyFilterLevel, - }), - ...(modelOptions?.seed !== undefined && { seed: modelOptions.seed }), - ...(modelOptions?.addWatermark !== undefined && { - addWatermark: modelOptions.addWatermark, - }), - ...(modelOptions?.language !== undefined && { - language: modelOptions.language, - }), - ...(modelOptions?.negativePrompt !== undefined && { - negativePrompt: modelOptions.negativePrompt, - }), - ...(modelOptions?.outputMimeType !== undefined && { - outputMimeType: modelOptions.outputMimeType, - }), - ...(modelOptions?.outputCompressionQuality !== undefined && { - outputCompressionQuality: modelOptions.outputCompressionQuality, - }), - ...(modelOptions?.guidanceScale !== undefined && { - guidanceScale: modelOptions.guidanceScale, - }), - ...(modelOptions?.enhancePrompt !== undefined && { - enhancePrompt: modelOptions.enhancePrompt, - }), - ...(modelOptions?.includeSafetyAttributes !== undefined && { - includeSafetyAttributes: modelOptions.includeSafetyAttributes, - }), - ...(modelOptions?.includeRaiReason !== undefined && { - includeRaiReason: modelOptions.includeRaiReason, - }), - ...(modelOptions?.outputGcsUri !== undefined && { - outputGcsUri: modelOptions.outputGcsUri, - }), - ...(modelOptions?.labels !== undefined && { - labels: modelOptions.labels, - }), } + const sizeAspectRatio = size ? sizeToAspectRatio(size) : undefined + assignDefined( + config, + 'aspectRatio', + modelOptions?.aspectRatio ?? sizeAspectRatio, + ) + if (!modelOptions) return config + assignDefined(config, 'personGeneration', modelOptions.personGeneration) + assignDefined(config, 'safetyFilterLevel', modelOptions.safetyFilterLevel) + assignDefined(config, 'seed', modelOptions.seed) + assignDefined(config, 'addWatermark', modelOptions.addWatermark) + assignDefined(config, 'language', modelOptions.language) + assignDefined(config, 'negativePrompt', modelOptions.negativePrompt) + assignDefined(config, 'outputMimeType', modelOptions.outputMimeType) + assignDefined( + config, + 'outputCompressionQuality', + modelOptions.outputCompressionQuality, + ) + assignDefined(config, 'guidanceScale', modelOptions.guidanceScale) + assignDefined(config, 'enhancePrompt', modelOptions.enhancePrompt) + assignDefined( + config, + 'includeSafetyAttributes', + modelOptions.includeSafetyAttributes, + ) + assignDefined(config, 'includeRaiReason', modelOptions.includeRaiReason) + assignDefined(config, 'outputGcsUri', modelOptions.outputGcsUri) + assignDefined(config, 'labels', modelOptions.labels) + return config } private transformImagenResponse( diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index c94f87a7cc..46d3bcb275 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -245,6 +245,7 @@ export class GeminiTextAdapter< logger: InternalLogger, ): AsyncIterable { const model = options.model + const adapterName = this.name let accumulatedContent = '' let accumulatedThinking = '' const toolCallMap = new Map< @@ -259,10 +260,9 @@ export class GeminiTextAdapter< >() let nextToolIndex = 0 - // AG-UI lifecycle tracking - const runId = options.runId ?? generateId(this.name) - const threadId = options.threadId ?? generateId(this.name) - const messageId = generateId(this.name) + const runId = options.runId ?? generateId(adapterName) + const threadId = options.threadId ?? generateId(adapterName) + const messageId = generateId(adapterName) let stepId: string | null = null let reasoningMessageId: string | null = null let hasClosedReasoning = false @@ -270,320 +270,299 @@ export class GeminiTextAdapter< let hasEmittedTextMessageStart = false let hasEmittedStepStarted = false - for await (const chunk of result) { - logger.provider(`provider=gemini`, { chunk }) - // Emit RUN_STARTED on first chunk - if (!hasEmittedRunStarted) { - hasEmittedRunStarted = true + const now = () => Date.now() + + function* emitRunStartedIfNeeded(): Generator { + if (hasEmittedRunStarted) return + hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + model, + timestamp: now(), + parentRunId: options.parentRunId, + } + } + + function* closeReasoningIfNeeded(): Generator { + if (!reasoningMessageId || hasClosedReasoning) return + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model, + timestamp: now(), + } + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model, + timestamp: now(), + } + } + + function* processThoughtPart(part: Part): Generator { + if (!hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(adapterName) + reasoningMessageId = generateId(adapterName) yield { - type: EventType.RUN_STARTED, - runId, - threadId, + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model, + timestamp: now(), + } + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning' as const, model, - timestamp: Date.now(), - parentRunId: options.parentRunId, + timestamp: now(), + } + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model, + timestamp: now(), + stepType: 'thinking', } } - if (chunk.candidates?.[0]?.content?.parts) { - const parts = chunk.candidates[0].content.parts - - for (const part of parts) { - if (part.text) { - if (part.thought) { - // Emit STEP_STARTED and REASONING events on first thinking content - if (!hasEmittedStepStarted) { - hasEmittedStepStarted = true - stepId = generateId(this.name) - reasoningMessageId = generateId(this.name) - - // Spec REASONING events - yield { - type: EventType.REASONING_START, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningMessageId, - role: 'reasoning' as const, - model, - timestamp: Date.now(), - } - - // Legacy STEP events (kept during transition) - yield { - type: EventType.STEP_STARTED, - stepName: stepId, - stepId, - model, - timestamp: Date.now(), - stepType: 'thinking', - } - } - - accumulatedThinking += part.text - - // Spec REASONING content event — reasoningMessageId is set in the - // hasEmittedStepStarted block above (entered on the same `part.thought` path) - if (!reasoningMessageId) continue - yield { - type: EventType.REASONING_MESSAGE_CONTENT, - messageId: reasoningMessageId, - delta: part.text, - model, - timestamp: Date.now(), - } + accumulatedThinking += part.text ?? '' + if (!reasoningMessageId) return + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: part.text, + model, + timestamp: now(), + } + yield { + type: EventType.STEP_FINISHED, + stepName: stepId || generateId(adapterName), + stepId: stepId || generateId(adapterName), + model, + timestamp: now(), + delta: part.text, + content: accumulatedThinking, + } + } - // Legacy STEP event - yield { - type: EventType.STEP_FINISHED, - stepName: stepId || generateId(this.name), - stepId: stepId || generateId(this.name), - model, - timestamp: Date.now(), - delta: part.text, - content: accumulatedThinking, - } - } else if (part.text.trim()) { - // Close reasoning before text starts - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } + function* processTextPart(text: string): Generator { + yield* closeReasoningIfNeeded() + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + model, + timestamp: now(), + role: 'assistant', + } + } + accumulatedContent += text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + model, + timestamp: now(), + delta: text, + content: accumulatedContent, + } + } - // Skip whitespace-only text parts (e.g. "\n" during auto-continuation) - // Emit TEXT_MESSAGE_START on first text content - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - model, - timestamp: Date.now(), - role: 'assistant', - } - } + function stringifyFunctionArgs(functionArgs: unknown): string { + return typeof functionArgs === 'string' + ? functionArgs + : JSON.stringify(functionArgs) + } - accumulatedContent += part.text - yield { - type: EventType.TEXT_MESSAGE_CONTENT, - messageId, - model, - timestamp: Date.now(), - delta: part.text, - content: accumulatedContent, - } - } - } + function mergeFunctionArgs( + existing: string, + functionArgs: unknown, + ): string { + try { + const existingArgs = JSON.parse(existing) + const newArgs = + typeof functionArgs === 'string' + ? JSON.parse(functionArgs) + : functionArgs + return JSON.stringify({ ...existingArgs, ...newArgs }) + } catch { + return stringifyFunctionArgs(functionArgs) + } + } - const functionCall = part.functionCall - if (functionCall) { - const toolCallId = - functionCall.id || - `${functionCall.name}_${Date.now()}_${nextToolIndex}` - const functionArgs = functionCall.args || {} - - // Gemini emits thoughtSignature as a Part-level sibling of - // functionCall (per @google/genai Part type), not nested inside - // functionCall itself. - const partThoughtSignature = part.thoughtSignature || undefined - - let toolCallData = toolCallMap.get(toolCallId) - if (!toolCallData) { - toolCallData = { - name: functionCall.name || '', - args: - typeof functionArgs === 'string' - ? functionArgs - : JSON.stringify(functionArgs), - index: nextToolIndex++, - started: false, - // Only set thoughtSignature when present — under EOPT, the - // optional field cannot accept an explicit `undefined`. - ...(partThoughtSignature !== undefined && { - thoughtSignature: partThoughtSignature, - }), - } - toolCallMap.set(toolCallId, toolCallData) - } else { - if (!toolCallData.thoughtSignature && partThoughtSignature) { - toolCallData.thoughtSignature = partThoughtSignature - } - try { - const existingArgs = JSON.parse(toolCallData.args) - const newArgs = - typeof functionArgs === 'string' - ? JSON.parse(functionArgs) - : functionArgs - const mergedArgs = { ...existingArgs, ...newArgs } - toolCallData.args = JSON.stringify(mergedArgs) - } catch { - toolCallData.args = - typeof functionArgs === 'string' - ? functionArgs - : JSON.stringify(functionArgs) - } - } - - // Emit TOOL_CALL_START if not already started - if (!toolCallData.started) { - toolCallData.started = true - yield { - type: EventType.TOOL_CALL_START, - toolCallId, - toolCallName: toolCallData.name, - toolName: toolCallData.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: toolCallData.index, - ...(toolCallData.thoughtSignature && { - metadata: { - thoughtSignature: toolCallData.thoughtSignature, - } satisfies GeminiToolCallMetadata, - }), - } - } - - // Emit TOOL_CALL_ARGS - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId, - model, - timestamp: Date.now(), - delta: toolCallData.args, - args: toolCallData.args, - } - } + function* processFunctionCallPart( + part: Part, + ): Generator { + const functionCall = part.functionCall + if (!functionCall) return + const toolCallId = + functionCall.id || `${functionCall.name}_${Date.now()}_${nextToolIndex}` + const functionArgs = functionCall.args || {} + const partThoughtSignature = part.thoughtSignature || undefined + + let toolCallData = toolCallMap.get(toolCallId) + if (!toolCallData) { + toolCallData = { + name: functionCall.name || '', + args: stringifyFunctionArgs(functionArgs), + index: nextToolIndex++, + started: false, + ...(partThoughtSignature !== undefined && { + thoughtSignature: partThoughtSignature, + }), } - } else if (chunk.data && chunk.data.trim()) { - // Skip whitespace-only data (e.g. "\n" during auto-continuation) - // Emit TEXT_MESSAGE_START on first text content - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - model, - timestamp: Date.now(), - role: 'assistant', - } + toolCallMap.set(toolCallId, toolCallData) + } else { + if (!toolCallData.thoughtSignature && partThoughtSignature) { + toolCallData.thoughtSignature = partThoughtSignature } + toolCallData.args = mergeFunctionArgs(toolCallData.args, functionArgs) + } - accumulatedContent += chunk.data + if (!toolCallData.started) { + toolCallData.started = true yield { - type: EventType.TEXT_MESSAGE_CONTENT, - messageId, + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: toolCallData.name, + toolName: toolCallData.name, + parentMessageId: messageId, model, - timestamp: Date.now(), - delta: chunk.data, - content: accumulatedContent, + timestamp: now(), + index: toolCallData.index, + ...(toolCallData.thoughtSignature && { + metadata: { + thoughtSignature: toolCallData.thoughtSignature, + } satisfies GeminiToolCallMetadata, + }), } } - if (chunk.candidates?.[0]?.finishReason) { - const finishReason = chunk.candidates[0].finishReason + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + model, + timestamp: now(), + delta: toolCallData.args, + args: toolCallData.args, + } + } - // Emit TOOL_CALL_END for all tracked tool calls. functionCall parts on - // this chunk (including UNEXPECTED_TOOL_CALL finishes) were already - // registered and started by the per-part loop above. - for (const [toolCallId, toolCallData] of toolCallMap.entries()) { - let parsedInput: unknown = {} - try { - const parsed = JSON.parse(toolCallData.args) - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = {} + function* processCandidateParts( + parts: Array, + ): Generator { + for (const part of parts) { + if (part.text) { + if (part.thought) { + yield* processThoughtPart(part) + } else if (part.text.trim()) { + yield* processTextPart(part.text) } + } + if (part.functionCall) { + yield* processFunctionCallPart(part) + } + } + } - yield { - type: EventType.TOOL_CALL_END, - toolCallId, - toolCallName: toolCallData.name, - toolName: toolCallData.name, - model, - timestamp: Date.now(), - input: parsedInput, - } + function* processDataFallback(data: string): Generator { + yield* processTextPart(data) + } + + function* processFinish( + chunk: GenerateContentResponse, + ): Generator { + const finishReason = chunk.candidates?.[0]?.finishReason + if (!finishReason) return + + for (const [toolCallId, toolCallData] of toolCallMap.entries()) { + let parsedInput: unknown = {} + try { + const parsed = JSON.parse(toolCallData.args) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + } catch { + parsedInput = {} } - // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls - if (toolCallMap.size > 0) { - hasEmittedTextMessageStart = false + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + toolCallName: toolCallData.name, + toolName: toolCallData.name, + model, + timestamp: now(), + input: parsedInput, } + } - if (finishReason === FinishReason.MAX_TOKENS) { - yield { - type: EventType.RUN_ERROR, - runId, - model, - timestamp: Date.now(), + if (toolCallMap.size > 0) { + hasEmittedTextMessageStart = false + } + + if (finishReason === FinishReason.MAX_TOKENS) { + yield { + type: EventType.RUN_ERROR, + runId, + model, + timestamp: now(), + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + error: { message: 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', - error: { - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - }, - } - } - - // Close reasoning events if still open - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } + }, } + } - // Emit TEXT_MESSAGE_END if we had text content - if (hasEmittedTextMessageStart) { - yield { - type: EventType.TEXT_MESSAGE_END, - messageId, - model, - timestamp: Date.now(), - } - } + yield* closeReasoningIfNeeded() + if (hasEmittedTextMessageStart) { yield { - type: EventType.RUN_FINISHED, - runId, - threadId, + type: EventType.TEXT_MESSAGE_END, + messageId, model, - timestamp: Date.now(), - finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop', - // RunFinishedEvent.usage is `usage?: {...}` (no `| undefined`) under - // exactOptionalPropertyTypes; only include it when usageMetadata is - // present rather than assigning an explicit `undefined`. - ...(chunk.usageMetadata && { - usage: buildGeminiUsage(chunk.usageMetadata), - }), + timestamp: now(), } } + + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + model, + timestamp: now(), + finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop', + ...(chunk.usageMetadata && { + usage: buildGeminiUsage(chunk.usageMetadata), + }), + } + } + + function* processChunkContent( + chunk: GenerateContentResponse, + ): Generator { + const parts = chunk.candidates?.[0]?.content?.parts + if (parts) { + yield* processCandidateParts(parts) + return + } + if (chunk.data && chunk.data.trim()) { + yield* processDataFallback(chunk.data) + } + } + + for await (const chunk of result) { + logger.provider(`provider=gemini`, { chunk }) + yield* emitRunStartedIfNeeded() + yield* processChunkContent(chunk) + yield* processFinish(chunk) } } @@ -628,127 +607,137 @@ export class GeminiTextAdapter< } } - private formatMessages( - messages: Array, - ): GenerateContentParameters['contents'] { - // Build a lookup from toolCallId → function name so functionResponse uses the - // correct name instead of the raw call ID. - const toolCallIdToName = new Map() - for (const msg of messages) { - if (msg.role === 'assistant' && msg.toolCalls) { - for (const tc of msg.toolCalls) { - toolCallIdToName.set(tc.id, tc.function.name) - } + private appendAssistantToolCallParts( + toolCalls: NonNullable, + parts: Array, + ): void { + for (const toolCall of toolCalls) { + let parsedArgs: Record = {} + try { + parsedArgs = toolCall.function.arguments + ? (JSON.parse(toolCall.function.arguments) as Record) + : {} + } catch { + parsedArgs = {} } - } - - const formatted = messages.map((msg) => { - const role: 'user' | 'model' = msg.role === 'assistant' ? 'model' : 'user' - const parts: Array = [] - if (Array.isArray(msg.content)) { - for (const contentPart of msg.content) { - parts.push(this.convertContentPartToGemini(contentPart)) - } - } else if (msg.content && msg.role !== 'tool') { - parts.push({ text: msg.content }) - } - - if (msg.role === 'assistant' && msg.toolCalls?.length) { - for (const toolCall of msg.toolCalls) { - let parsedArgs: Record = {} - try { - parsedArgs = toolCall.function.arguments - ? (JSON.parse(toolCall.function.arguments) as Record< - string, - unknown - >) - : {} - } catch { - parsedArgs = {} - } - - const thoughtSignature = ( - toolCall.metadata as GeminiToolCallMetadata | undefined - )?.thoughtSignature - // Gemini requires thoughtSignature at the Part level (sibling of - // functionCall), not nested inside functionCall. Nesting it causes - // the API to reject the next turn with - // "Function call is missing a thought_signature". - const part: Part = { - functionCall: { - id: toolCall.id, - name: toolCall.function.name, - args: parsedArgs, - }, - } - if (thoughtSignature) { - part.thoughtSignature = thoughtSignature - } - parts.push(part) - } + const thoughtSignature = ( + toolCall.metadata as GeminiToolCallMetadata | undefined + )?.thoughtSignature + const part: Part = { + functionCall: { + id: toolCall.id, + name: toolCall.function.name, + args: parsedArgs, + }, + } + if (thoughtSignature) { + part.thoughtSignature = thoughtSignature } + parts.push(part) + } + } - if (msg.role === 'tool' && msg.toolCallId) { - const functionName = - toolCallIdToName.get(msg.toolCallId) || msg.toolCallId - const toolContent = msg.content - if (Array.isArray(toolContent)) { - const textChunks: Array = [] - const mediaParts: Array = [] - for (const part of toolContent) { - if (part.type === 'text') { - textChunks.push(part.content) - } else if (part.source.type === 'data') { - mediaParts.push({ - inlineData: { - data: part.source.value, - mimeType: part.source.mimeType, - }, - }) - } else { - const defaultMimeType = { - image: 'image/jpeg', - audio: 'audio/mp3', - video: 'video/mp4', - document: 'application/pdf', - }[part.type] - mediaParts.push({ - fileData: { - fileUri: part.source.value, - mimeType: part.source.mimeType ?? defaultMimeType, - }, - }) - } - } - parts.push({ - functionResponse: { - id: msg.toolCallId, - name: functionName, - response: { content: textChunks.join('\n') }, - ...(mediaParts.length > 0 && { parts: mediaParts }), + private appendToolResultParts( + msg: ModelMessage, + parts: Array, + toolCallIdToName: Map, + ): void { + if (!msg.toolCallId) return + const functionName = toolCallIdToName.get(msg.toolCallId) || msg.toolCallId + const toolContent = msg.content + if (Array.isArray(toolContent)) { + const textChunks: Array = [] + const mediaParts: Array = [] + for (const part of toolContent) { + if (part.type === 'text') { + textChunks.push(part.content) + } else if (part.source.type === 'data') { + mediaParts.push({ + inlineData: { + data: part.source.value, + mimeType: part.source.mimeType, }, }) } else { - parts.push({ - functionResponse: { - id: msg.toolCallId, - name: functionName, - response: { content: toolContent || '' }, + const defaultMimeType = { + image: 'image/jpeg', + audio: 'audio/mp3', + video: 'video/mp4', + document: 'application/pdf', + }[part.type] + mediaParts.push({ + fileData: { + fileUri: part.source.value, + mimeType: part.source.mimeType ?? defaultMimeType, }, }) } } + parts.push({ + functionResponse: { + id: msg.toolCallId, + name: functionName, + response: { content: textChunks.join('\n') }, + ...(mediaParts.length > 0 && { parts: mediaParts }), + }, + }) + return + } + parts.push({ + functionResponse: { + id: msg.toolCallId, + name: functionName, + response: { content: toolContent || '' }, + }, + }) + } - return { - role, - parts: parts.length > 0 ? parts : [{ text: '' }], + private formatOneMessage( + msg: ModelMessage, + toolCallIdToName: Map, + ): Content { + const role: 'user' | 'model' = msg.role === 'assistant' ? 'model' : 'user' + const parts: Array = [] + + if (Array.isArray(msg.content)) { + for (const contentPart of msg.content) { + parts.push(this.convertContentPartToGemini(contentPart)) } - }) + } else if (msg.content && msg.role !== 'tool') { + parts.push({ text: msg.content }) + } + + if (msg.role === 'assistant' && msg.toolCalls?.length) { + this.appendAssistantToolCallParts(msg.toolCalls, parts) + } + + if (msg.role === 'tool' && msg.toolCallId) { + this.appendToolResultParts(msg, parts, toolCallIdToName) + } + + return { + role, + parts: parts.length > 0 ? parts : [{ text: '' }], + } + } + + private formatMessages( + messages: Array, + ): GenerateContentParameters['contents'] { + const toolCallIdToName = new Map() + for (const msg of messages) { + if (msg.role === 'assistant' && msg.toolCalls) { + for (const tc of msg.toolCalls) { + toolCallIdToName.set(tc.id, tc.function.name) + } + } + } + + const formatted = messages.map((msg) => + this.formatOneMessage(msg, toolCallIdToName), + ) - // Post-process: Gemini requires strictly alternating user/model roles. - // Tool results are mapped to role:'user', which can create consecutive - // user messages when followed by a new user message. Merge them. return this.mergeConsecutiveSameRoleMessages(formatted) } diff --git a/packages/ai-gemini/src/adapters/tts.ts b/packages/ai-gemini/src/adapters/tts.ts index 249aadf48d..9c07546026 100644 --- a/packages/ai-gemini/src/adapters/tts.ts +++ b/packages/ai-gemini/src/adapters/tts.ts @@ -8,7 +8,11 @@ import { GEMINI_TTS_VOICES } from '../model-meta' import { buildGeminiUsage } from '../usage' import type { GEMINI_TTS_MODELS, GeminiTTSVoice } from '../model-meta' import type { TTSOptions, TTSResult } from '@tanstack/ai' -import type { GoogleGenAI, SpeechConfig } from '@google/genai' +import type { + GenerateContentResponse, + GoogleGenAI, + SpeechConfig, +} from '@google/genai' import type { GeminiClientConfig } from '../utils/client' /** @@ -145,49 +149,7 @@ export class GeminiTTSAdapter< model, }) - const speechConfig: SpeechConfig = {} - - if (modelOptions?.multiSpeakerVoiceConfig) { - // Validate multi-speaker config: 1 or 2 speakers allowed. - const speakerConfigs = - modelOptions.multiSpeakerVoiceConfig.speakerVoiceConfigs - if ( - !Array.isArray(speakerConfigs) || - speakerConfigs.length < 1 || - speakerConfigs.length > 2 - ) { - throw new Error( - `Gemini TTS multiSpeakerVoiceConfig.speakerVoiceConfigs must contain 1 or 2 speakers; received ${Array.isArray(speakerConfigs) ? speakerConfigs.length : 'non-array'}.`, - ) - } - speechConfig.multiSpeakerVoiceConfig = - modelOptions.multiSpeakerVoiceConfig - } else { - // Honor the standard TTSOptions.voice (used by every other TTS adapter) - // as a fallback for the prebuilt voice name. If an explicit - // modelOptions.voiceConfig is supplied its values win — but we still - // fall back to `voice` / 'Kore' if the supplied voiceConfig is missing - // prebuiltVoiceConfig.voiceName. - if ( - voice !== undefined && - !(GEMINI_TTS_VOICES as ReadonlyArray).includes(voice) - ) { - throw new Error( - `Invalid Gemini TTS voice "${voice}". Valid voices are: ${GEMINI_TTS_VOICES.join(', ')}.`, - ) - } - const defaultVoiceName = (voice as GeminiTTSVoice | undefined) ?? 'Kore' - const supplied = modelOptions?.voiceConfig - const resolvedVoiceName = - supplied?.prebuiltVoiceConfig?.voiceName ?? defaultVoiceName - speechConfig.voiceConfig = { - prebuiltVoiceConfig: { voiceName: resolvedVoiceName }, - } - } - - if (modelOptions?.languageCode) { - speechConfig.languageCode = modelOptions.languageCode - } + const speechConfig = buildSpeechConfig(modelOptions, voice) try { const response = await this.client.models.generateContent({ @@ -201,77 +163,13 @@ export class GeminiTTSAdapter< config: { responseModalities: ['AUDIO'], speechConfig, - // systemInstruction belongs inside `config` per the @google/genai - // contract — matches sibling Gemini adapters (summarize, text). ...(modelOptions?.systemInstruction && { systemInstruction: modelOptions.systemInstruction, }), }, }) - // Extract audio data from response - const candidate = response.candidates?.[0] - const parts = candidate?.content?.parts - - if (!parts || parts.length === 0) { - throw new Error('No audio output received from Gemini TTS') - } - - // Look for inline data (audio) - const audioPart = parts.find((part: any) => - part.inlineData?.mimeType?.startsWith('audio/'), - ) - - if (!audioPart || !audioPart.inlineData || !audioPart.inlineData.data) { - throw new Error('No audio data in Gemini TTS response') - } - - const audioBase64 = audioPart.inlineData.data - // mime is guaranteed by the `startsWith('audio/')` find predicate above. - const mimeType = audioPart.inlineData.mimeType as string - - // Surface token usage (with per-modality breakdown) when Gemini reports - // it. Spread conditionally for exactOptionalPropertyTypes — shared by both - // the PCM→WAV and pass-through return paths below. - const usageField = response.usageMetadata - ? { usage: buildGeminiUsage(response.usageMetadata) } - : {} - - // Gemini TTS models return raw 16-bit LE PCM with a mime type like - // `audio/L16;codec=pcm;rate=24000`. That isn't playable in an