UNPKG

@tanstack/ai

Version:

Core TanStack AI library - Open source AI SDK

1,529 lines (1,366 loc) 46.9 kB
/** * Text Activity * * Handles agentic text generation, one-shot text generation, and agentic structured output. * This is a self-contained module with implementation, types, and JSDoc. */ import { devtoolsMiddleware } from '@tanstack/ai-event-client' import { streamToText } from '../../stream-to-response.js' import { LazyToolManager } from './tools/lazy-tool-manager' import { MiddlewareAbortError, ToolCallManager, executeToolCalls, } from './tools/tool-calls' import { convertSchemaToJsonSchema, isStandardSchema, parseWithStandardSchema, } from './tools/schema-converter' import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies' import { convertMessagesToModelMessages } from './messages' import { MiddlewareRunner } from './middleware/compose' import type { ApprovalRequest, ClientToolRequest, ToolResult, } from './tools/tool-calls' import type { AnyTextAdapter } from './adapter' import type { AgentLoopStrategy, ConstrainedModelMessage, CustomEvent, InferSchemaType, ModelMessage, RunFinishedEvent, SchemaInput, StreamChunk, TextMessageContentEvent, TextOptions, Tool, ToolCall, ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent, } from '../../types' import type { ChatMiddleware, ChatMiddlewareConfig, ChatMiddlewareContext, ChatMiddlewarePhase, } from './middleware/types' // =========================== // Activity Kind // =========================== /** The adapter kind this activity handles */ export const kind = 'text' as const // =========================== // Activity Options Type // =========================== /** * Options for the text activity. * Types are extracted directly from the adapter (which has pre-resolved generics). * * @template TAdapter - The text adapter type (created by a provider function) * @template TSchema - Optional Standard Schema for structured output * @template TStream - Whether to stream the output (default: true) */ export interface TextActivityOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined, TStream extends boolean, > { /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ adapter: TAdapter /** Conversation messages - content types are constrained by the adapter's input modalities and metadata */ messages?: Array< ConstrainedModelMessage<{ inputModalities: TAdapter['~types']['inputModalities'] messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] }> > /** System prompts to prepend to the conversation */ systemPrompts?: TextOptions['systemPrompts'] /** Tools for function calling (auto-executed when called) */ tools?: TextOptions['tools'] /** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */ temperature?: TextOptions['temperature'] /** Nucleus sampling parameter. The model considers tokens with topP probability mass. */ topP?: TextOptions['topP'] /** The maximum number of tokens to generate in the response. */ maxTokens?: TextOptions['maxTokens'] /** Additional metadata to attach to the request. */ metadata?: TextOptions['metadata'] /** Model-specific provider options (type comes from adapter) */ modelOptions?: TAdapter['~types']['providerOptions'] /** AbortController for cancellation */ abortController?: TextOptions['abortController'] /** Strategy for controlling the agent loop */ agentLoopStrategy?: TextOptions['agentLoopStrategy'] /** Unique conversation identifier for tracking */ conversationId?: TextOptions['conversationId'] /** * Optional Standard Schema for structured output. * When provided, the activity will: * 1. Run the full agentic loop (executing tools as needed) * 2. Once complete, return a Promise with the parsed output matching the schema * * Supports any Standard Schema compliant library (Zod v4+, ArkType, Valibot, etc.) * * @example * ```ts * const result = await chat({ * adapter: openaiText('gpt-4o'), * messages: [{ role: 'user', content: 'Generate a person' }], * outputSchema: z.object({ name: z.string(), age: z.number() }) * }) * // result is { name: string, age: number } * ``` */ outputSchema?: TSchema /** * Whether to stream the text result. * When true (default), returns an AsyncIterable<StreamChunk> for streaming output. * When false, returns a Promise<string> with the collected text content. * * Note: If outputSchema is provided, this option is ignored and the result * is always a Promise<InferSchemaType<TSchema>>. * * @default true * * @example Non-streaming text * ```ts * const text = await chat({ * adapter: openaiText('gpt-4o'), * messages: [{ role: 'user', content: 'Hello!' }], * stream: false * }) * // text is a string with the full response * ``` */ stream?: TStream /** * Optional middleware array for observing/transforming chat behavior. * Middleware hooks are called in array order. See {@link ChatMiddleware} for available hooks. * * @example * ```ts * const stream = chat({ * adapter: openaiText('gpt-4o'), * messages: [...], * middleware: [loggingMiddleware, redactionMiddleware], * }) * ``` */ middleware?: Array<ChatMiddleware> /** * Opaque user-provided context value passed to middleware hooks. * Can be used to pass request-scoped data (e.g., user ID, request context). */ context?: unknown } // =========================== // Chat Options Helper // =========================== /** * Create typed options for the chat() function without executing. * This is useful for pre-defining configurations with full type inference. * * @example * ```ts * const chatOptions = createChatOptions({ * adapter: anthropicText('claude-sonnet-4-5'), * }) * * const stream = chat({ ...chatOptions, messages }) * ``` */ export function createChatOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, >( options: TextActivityOptions<TAdapter, TSchema, TStream>, ): TextActivityOptions<TAdapter, TSchema, TStream> { return options } // =========================== // Activity Result Type // =========================== /** * Result type for the text activity. * - If outputSchema is provided: Promise<InferSchemaType<TSchema>> * - If stream is false: Promise<string> * - Otherwise (stream is true, default): AsyncIterable<StreamChunk> */ export type TextActivityResult< TSchema extends SchemaInput | undefined, TStream extends boolean = true, > = TSchema extends SchemaInput ? Promise<InferSchemaType<TSchema>> : TStream extends false ? Promise<string> : AsyncIterable<StreamChunk> // =========================== // ChatEngine Implementation // =========================== interface TextEngineConfig< TAdapter extends AnyTextAdapter, TParams extends TextOptions<any, any> = TextOptions<any>, > { adapter: TAdapter systemPrompts?: Array<string> params: TParams middleware?: Array<ChatMiddleware> context?: unknown } type ToolPhaseResult = 'continue' | 'stop' | 'wait' type CyclePhase = 'processText' | 'executeToolCalls' class TextEngine< TAdapter extends AnyTextAdapter, TParams extends TextOptions<any, any> = TextOptions<any>, > { private readonly adapter: TAdapter private params: TParams private systemPrompts: Array<string> private tools: Array<Tool> private readonly loopStrategy: AgentLoopStrategy private toolCallManager: ToolCallManager private readonly lazyToolManager: LazyToolManager private readonly initialMessageCount: number private readonly requestId: string private readonly streamId: string private readonly effectiveRequest?: Request | RequestInit private readonly effectiveSignal?: AbortSignal private messages: Array<ModelMessage> private iterationCount = 0 private lastFinishReason: string | null = null private streamStartTime = 0 private totalChunkCount = 0 private currentMessageId: string | null = null private accumulatedContent = '' private eventOptions?: Record<string, unknown> private eventToolNames?: Array<string> private finishedEvent: RunFinishedEvent | null = null private earlyTermination = false private toolPhase: ToolPhaseResult = 'continue' private cyclePhase: CyclePhase = 'processText' // Client state extracted from initial messages (before conversion to ModelMessage) private readonly initialApprovals: Map<string, boolean> private readonly initialClientToolResults: Map<string, any> // Middleware support private readonly middlewareRunner: MiddlewareRunner private readonly middlewareCtx: ChatMiddlewareContext private readonly deferredPromises: Array<Promise<unknown>> = [] private abortReason?: string private middlewareAbortController?: AbortController private terminalHookCalled = false constructor(config: TextEngineConfig<TAdapter, TParams>) { this.adapter = config.adapter this.params = config.params this.systemPrompts = config.params.systemPrompts || [] this.loopStrategy = config.params.agentLoopStrategy || maxIterationsStrategy(5) this.initialMessageCount = config.params.messages.length // Extract client state (approvals, client tool results) from original messages BEFORE conversion // This preserves UIMessage parts data that would be lost during conversion to ModelMessage const { approvals, clientToolResults } = this.extractClientStateFromOriginalMessages( config.params.messages as Array<any>, ) this.initialApprovals = approvals this.initialClientToolResults = clientToolResults // Convert messages to ModelMessage format (handles both UIMessage and ModelMessage input) // This ensures consistent internal format regardless of what the client sends this.messages = convertMessagesToModelMessages( config.params.messages as Array<any>, ) // Initialize lazy tool manager after messages are converted (needs message history for scanning) this.lazyToolManager = new LazyToolManager( config.params.tools || [], this.messages, ) this.tools = this.lazyToolManager.getActiveTools() this.toolCallManager = new ToolCallManager(this.tools) this.requestId = this.createId('chat') this.streamId = this.createId('stream') this.effectiveRequest = config.params.abortController ? { signal: config.params.abortController.signal } : undefined this.effectiveSignal = config.params.abortController?.signal // Initialize middleware — devtools middleware is always first const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || [])] this.middlewareRunner = new MiddlewareRunner(allMiddleware) this.middlewareAbortController = new AbortController() this.middlewareCtx = { requestId: this.requestId, streamId: this.streamId, conversationId: config.params.conversationId, phase: 'init' as ChatMiddlewarePhase, iteration: 0, chunkIndex: 0, signal: this.effectiveSignal, abort: (reason?: string) => { this.abortReason = reason this.middlewareAbortController?.abort(reason) }, context: config.context, defer: (promise: Promise<unknown>) => { this.deferredPromises.push(promise) }, // Provider / adapter info provider: config.adapter.name, model: config.params.model, source: 'server', streaming: true, // Config-derived (updated in beforeRun and applyMiddlewareConfig) systemPrompts: this.systemPrompts, toolNames: undefined, options: undefined, modelOptions: config.params.modelOptions, // Computed messageCount: this.initialMessageCount, hasTools: this.tools.length > 0, // Mutable per-iteration currentMessageId: null, accumulatedContent: '', // References messages: this.messages, createId: (prefix: string) => this.createId(prefix), } } /** Get the accumulated content after the chat loop completes */ getAccumulatedContent(): string { return this.accumulatedContent } /** Get the final messages array after the chat loop completes */ getMessages(): Array<ModelMessage> { return this.messages } async *run(): AsyncGenerator<StreamChunk> { this.beforeRun() try { // Run initial onConfig (phase = init) this.middlewareCtx.phase = 'init' const initialConfig = this.buildMiddlewareConfig() const transformedConfig = await this.middlewareRunner.runOnConfig( this.middlewareCtx, initialConfig, ) this.applyMiddlewareConfig(transformedConfig) // Run onStart (devtools middleware emits text:request:started and initial messages here) await this.middlewareRunner.runOnStart(this.middlewareCtx) const pendingPhase = yield* this.checkForPendingToolCalls() if (pendingPhase === 'wait') { return } do { if (this.earlyTermination || this.isCancelled()) { return } await this.beginCycle() if (this.cyclePhase === 'processText') { // Run onConfig before each model call (phase = beforeModel) this.middlewareCtx.phase = 'beforeModel' this.middlewareCtx.iteration = this.iterationCount const iterConfig = this.buildMiddlewareConfig() const transformedConfig = await this.middlewareRunner.runOnConfig( this.middlewareCtx, iterConfig, ) this.applyMiddlewareConfig(transformedConfig) yield* this.streamModelResponse() } else { yield* this.processToolCalls() } this.endCycle() } while (this.shouldContinue()) // Call terminal onFinish hook (skip when waiting for client — stream is paused, not finished) if (!this.terminalHookCalled && this.toolPhase !== 'wait') { this.terminalHookCalled = true await this.middlewareRunner.runOnFinish(this.middlewareCtx, { finishReason: this.lastFinishReason, duration: Date.now() - this.streamStartTime, content: this.accumulatedContent, usage: this.finishedEvent?.usage, }) } } catch (error: unknown) { if (!this.terminalHookCalled) { this.terminalHookCalled = true if (error instanceof MiddlewareAbortError) { // Middleware abort decision — call onAbort, not onError this.abortReason = error.message await this.middlewareRunner.runOnAbort(this.middlewareCtx, { reason: error.message, duration: Date.now() - this.streamStartTime, }) } else { // Genuine error — call onError await this.middlewareRunner.runOnError(this.middlewareCtx, { error, duration: Date.now() - this.streamStartTime, }) } } // Don't rethrow middleware abort errors — the run just stops gracefully if (!(error instanceof MiddlewareAbortError)) { throw error } } finally { // Check for abort terminal hook if (!this.terminalHookCalled && this.isCancelled()) { this.terminalHookCalled = true await this.middlewareRunner.runOnAbort(this.middlewareCtx, { reason: this.abortReason, duration: Date.now() - this.streamStartTime, }) } // Await deferred promises (non-blocking side effects) if (this.deferredPromises.length > 0) { await Promise.allSettled(this.deferredPromises) } } } private beforeRun(): void { this.streamStartTime = Date.now() const { tools, temperature, topP, maxTokens, metadata } = this.params // Gather flattened options into an object for context const options: Record<string, unknown> = {} if (temperature !== undefined) options.temperature = temperature if (topP !== undefined) options.topP = topP if (maxTokens !== undefined) options.maxTokens = maxTokens if (metadata !== undefined) options.metadata = metadata this.eventOptions = Object.keys(options).length > 0 ? options : undefined this.eventToolNames = tools?.map((t) => t.name) // Update middleware context with computed fields this.middlewareCtx.options = this.eventOptions this.middlewareCtx.toolNames = this.eventToolNames } private async beginCycle(): Promise<void> { if (this.cyclePhase === 'processText') { await this.beginIteration() } } private endCycle(): void { if (this.cyclePhase === 'processText') { this.cyclePhase = 'executeToolCalls' return } this.cyclePhase = 'processText' this.iterationCount++ } private async beginIteration(): Promise<void> { this.currentMessageId = this.createId('msg') this.accumulatedContent = '' this.finishedEvent = null // Update mutable context fields this.middlewareCtx.currentMessageId = this.currentMessageId this.middlewareCtx.accumulatedContent = '' // Notify middleware of new iteration (devtools emits assistant message:created here) await this.middlewareRunner.runOnIteration(this.middlewareCtx, { iteration: this.iterationCount, messageId: this.currentMessageId, }) } private async *streamModelResponse(): AsyncGenerator<StreamChunk> { const { temperature, topP, maxTokens, metadata, modelOptions } = this.params const tools = this.tools // Convert tool schemas to JSON Schema before passing to adapter const toolsWithJsonSchemas = tools.map((tool) => ({ ...tool, inputSchema: tool.inputSchema ? convertSchemaToJsonSchema(tool.inputSchema) : undefined, outputSchema: tool.outputSchema ? convertSchemaToJsonSchema(tool.outputSchema) : undefined, })) this.middlewareCtx.phase = 'modelStream' for await (const chunk of this.adapter.chatStream({ model: this.params.model, messages: this.messages, tools: toolsWithJsonSchemas, temperature, topP, maxTokens, metadata, request: this.effectiveRequest, modelOptions, systemPrompts: this.systemPrompts, })) { if (this.isCancelled()) { break } this.totalChunkCount++ // Pipe chunk through middleware (devtools middleware observes and emits events) const outputChunks = await this.middlewareRunner.runOnChunk( this.middlewareCtx, chunk, ) for (const outputChunk of outputChunks) { yield outputChunk this.handleStreamChunk(outputChunk) this.middlewareCtx.chunkIndex++ } // Handle usage via middleware if (chunk.type === 'RUN_FINISHED' && chunk.usage) { await this.middlewareRunner.runOnUsage(this.middlewareCtx, chunk.usage) } if (this.earlyTermination) { break } } } private handleStreamChunk(chunk: StreamChunk): void { switch (chunk.type) { // AG-UI Events case 'TEXT_MESSAGE_CONTENT': this.handleTextMessageContentEvent(chunk) break case 'TOOL_CALL_START': this.handleToolCallStartEvent(chunk) break case 'TOOL_CALL_ARGS': this.handleToolCallArgsEvent(chunk) break case 'TOOL_CALL_END': this.handleToolCallEndEvent(chunk) break case 'RUN_FINISHED': this.handleRunFinishedEvent(chunk) break case 'RUN_ERROR': this.handleRunErrorEvent(chunk) break case 'STEP_FINISHED': this.handleStepFinishedEvent(chunk) break default: // RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_END, STEP_STARTED, // STATE_SNAPSHOT, STATE_DELTA, CUSTOM // - no special handling needed in chat activity break } } // =========================== // AG-UI Event Handlers // =========================== private handleTextMessageContentEvent(chunk: TextMessageContentEvent): void { if (chunk.content) { this.accumulatedContent = chunk.content } else { this.accumulatedContent += chunk.delta } this.middlewareCtx.accumulatedContent = this.accumulatedContent } private handleToolCallStartEvent(chunk: ToolCallStartEvent): void { this.toolCallManager.addToolCallStartEvent(chunk) } private handleToolCallArgsEvent(chunk: ToolCallArgsEvent): void { this.toolCallManager.addToolCallArgsEvent(chunk) } private handleToolCallEndEvent(chunk: ToolCallEndEvent): void { this.toolCallManager.completeToolCall(chunk) } private handleRunFinishedEvent(chunk: RunFinishedEvent): void { this.finishedEvent = chunk this.lastFinishReason = chunk.finishReason } private handleRunErrorEvent( _chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>, ): void { this.earlyTermination = true } private handleStepFinishedEvent( _chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>, ): void { // State tracking for STEP_FINISHED is handled by middleware } private async *checkForPendingToolCalls(): AsyncGenerator< StreamChunk, ToolPhaseResult, void > { const pendingToolCalls = this.getPendingToolCallsFromMessages() if (pendingToolCalls.length === 0) { return 'continue' } const finishEvent = this.createSyntheticFinishedEvent() // Handle undiscovered lazy tool calls with self-correcting error messages const undiscoveredLazyResults: Array<ToolResult> = [] const executablePendingCalls = pendingToolCalls.filter((tc) => { if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) { undiscoveredLazyResults.push({ toolCallId: tc.id, toolName: tc.function.name, result: { error: this.lazyToolManager.getUndiscoveredToolError( tc.function.name, ), }, state: 'output-error', }) return false } return true }) if (undiscoveredLazyResults.length > 0) { for (const chunk of this.buildToolResultChunks( undiscoveredLazyResults, finishEvent, )) { yield chunk } } if (executablePendingCalls.length === 0) { return 'continue' } const { approvals, clientToolResults } = this.collectClientState() const generator = executeToolCalls( executablePendingCalls, this.tools, approvals, clientToolResults, (eventName, data) => this.createCustomEventChunk(eventName, data), { onBeforeToolCall: async (toolCall, tool, args) => { const hookCtx = { toolCall, tool, args, toolName: toolCall.function.name, toolCallId: toolCall.id, } return this.middlewareRunner.runOnBeforeToolCall( this.middlewareCtx, hookCtx, ) }, onAfterToolCall: async (info) => { await this.middlewareRunner.runOnAfterToolCall( this.middlewareCtx, info, ) }, }, ) // Consume the async generator, yielding custom events and collecting the return value const executionResult = yield* this.drainToolCallGenerator(generator) // Check if middleware aborted during pending tool execution if (this.isMiddlewareAborted()) { this.setToolPhase('stop') return 'stop' } // Notify middleware of tool phase completion (devtools emits aggregate events here) await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, { toolCalls: pendingToolCalls, results: executionResult.results, needsApproval: executionResult.needsApproval, needsClientExecution: executionResult.needsClientExecution, }) // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START + // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions. const argsMap = new Map<string, string>() for (const tc of pendingToolCalls) { argsMap.set(tc.id, tc.function.arguments) } if ( executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0 ) { if (executionResult.results.length > 0) { for (const chunk of this.buildToolResultChunks( executionResult.results, finishEvent, argsMap, )) { yield chunk } } for (const chunk of this.buildApprovalChunks( executionResult.needsApproval, finishEvent, )) { yield chunk } for (const chunk of this.buildClientToolChunks( executionResult.needsClientExecution, finishEvent, )) { yield chunk } this.setToolPhase('wait') return 'wait' } const toolResultChunks = this.buildToolResultChunks( executionResult.results, finishEvent, argsMap, ) for (const chunk of toolResultChunks) { yield chunk } return 'continue' } private async *processToolCalls(): AsyncGenerator<StreamChunk, void, void> { if (!this.shouldExecuteToolPhase()) { this.setToolPhase('stop') return } const toolCalls = this.toolCallManager.getToolCalls() const finishEvent = this.finishedEvent if (!finishEvent || toolCalls.length === 0) { this.setToolPhase('stop') return } this.addAssistantToolCallMessage(toolCalls) // Handle undiscovered lazy tool calls with self-correcting error messages const undiscoveredLazyResults: Array<ToolResult> = [] const executableToolCalls = toolCalls.filter((tc) => { if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) { undiscoveredLazyResults.push({ toolCallId: tc.id, toolName: tc.function.name, result: { error: this.lazyToolManager.getUndiscoveredToolError( tc.function.name, ), }, state: 'output-error', }) return false } return true }) if (undiscoveredLazyResults.length > 0) { const finishEvt = this.finishedEvent! for (const chunk of this.buildToolResultChunks( undiscoveredLazyResults, finishEvt, )) { yield chunk } } if (executableToolCalls.length === 0) { // All tool calls were undiscovered lazy tools — errors emitted, continue loop this.toolCallManager.clear() this.setToolPhase('continue') return } this.middlewareCtx.phase = 'beforeTools' const { approvals, clientToolResults } = this.collectClientState() const generator = executeToolCalls( executableToolCalls, this.tools, approvals, clientToolResults, (eventName, data) => this.createCustomEventChunk(eventName, data), { onBeforeToolCall: async (toolCall, tool, args) => { const hookCtx = { toolCall, tool, args, toolName: toolCall.function.name, toolCallId: toolCall.id, } return this.middlewareRunner.runOnBeforeToolCall( this.middlewareCtx, hookCtx, ) }, onAfterToolCall: async (info) => { await this.middlewareRunner.runOnAfterToolCall( this.middlewareCtx, info, ) }, }, ) // Consume the async generator, yielding custom events and collecting the return value const executionResult = yield* this.drainToolCallGenerator(generator) this.middlewareCtx.phase = 'afterTools' // Check if middleware aborted during tool execution if (this.isMiddlewareAborted()) { this.setToolPhase('stop') return } // Notify middleware of tool phase completion (devtools emits aggregate events here) await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, { toolCalls, results: executionResult.results, needsApproval: executionResult.needsApproval, needsClientExecution: executionResult.needsClientExecution, }) if ( executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0 ) { if (executionResult.results.length > 0) { for (const chunk of this.buildToolResultChunks( executionResult.results, finishEvent, )) { yield chunk } } for (const chunk of this.buildApprovalChunks( executionResult.needsApproval, finishEvent, )) { yield chunk } for (const chunk of this.buildClientToolChunks( executionResult.needsClientExecution, finishEvent, )) { yield chunk } this.setToolPhase('wait') return } const toolResultChunks = this.buildToolResultChunks( executionResult.results, finishEvent, ) for (const chunk of toolResultChunks) { yield chunk } // Refresh tools if lazy tools were discovered in this batch if (this.lazyToolManager.hasNewlyDiscoveredTools()) { this.tools = this.lazyToolManager.getActiveTools() this.toolCallManager = new ToolCallManager(this.tools) this.setToolPhase('continue') return } this.toolCallManager.clear() this.setToolPhase('continue') } private shouldExecuteToolPhase(): boolean { return ( this.finishedEvent?.finishReason === 'tool_calls' && this.tools.length > 0 && this.toolCallManager.hasToolCalls() ) } private addAssistantToolCallMessage(toolCalls: Array<ToolCall>): void { this.messages = [ ...this.messages, { role: 'assistant', content: this.accumulatedContent || null, toolCalls, }, ] } /** * Extract client state (approvals and client tool results) from original messages. * This is called in the constructor BEFORE converting to ModelMessage format, * because the parts array (which contains approval state) is lost during conversion. */ private extractClientStateFromOriginalMessages( originalMessages: Array<any>, ): { approvals: Map<string, boolean> clientToolResults: Map<string, any> } { const approvals = new Map<string, boolean>() const clientToolResults = new Map<string, any>() for (const message of originalMessages) { // Check for UIMessage format (parts array) - extract client tool results and approvals if (message.role === 'assistant' && message.parts) { for (const part of message.parts) { if (part.type === 'tool-call') { // Extract client tool results (tools without approval that have output) if (part.output !== undefined && !part.approval) { clientToolResults.set(part.id, part.output) } // Extract approval responses from UIMessage format parts if ( part.approval?.id && part.approval?.approved !== undefined && part.state === 'approval-responded' ) { approvals.set(part.approval.id, part.approval.approved) } } } } } return { approvals, clientToolResults } } private collectClientState(): { approvals: Map<string, boolean> clientToolResults: Map<string, any> } { // Start with the initial client state extracted from original messages const approvals = new Map(this.initialApprovals) const clientToolResults = new Map(this.initialClientToolResults) // Also check current messages for any additional tool results (from server tools) for (const message of this.messages) { // Check for ModelMessage format (role: 'tool' messages contain tool results) // This handles results sent back from the client after executing client-side tools if (message.role === 'tool' && message.toolCallId) { // Parse content back to original output (was stringified by uiMessageToModelMessages) let output: unknown try { output = JSON.parse(message.content as string) } catch { output = message.content } // Skip approval response messages (they have pendingExecution marker) // These are NOT real client tool results — they are synthetic tool messages // created by uiMessageToModelMessages for approved-but-not-yet-executed tools. // Treating them as results would prevent the server from requesting actual // client-side execution after approval (see GitHub issue #225). if ( output && typeof output === 'object' && (output as any).pendingExecution === true ) { continue } clientToolResults.set(message.toolCallId, output) } } return { approvals, clientToolResults } } private buildApprovalChunks( approvals: Array<ApprovalRequest>, finishEvent: RunFinishedEvent, ): Array<StreamChunk> { const chunks: Array<StreamChunk> = [] for (const approval of approvals) { chunks.push({ type: 'CUSTOM', timestamp: Date.now(), model: finishEvent.model, name: 'approval-requested', value: { toolCallId: approval.toolCallId, toolName: approval.toolName, input: approval.input, approval: { id: approval.approvalId, needsApproval: true, }, }, }) } return chunks } private buildClientToolChunks( clientRequests: Array<ClientToolRequest>, finishEvent: RunFinishedEvent, ): Array<StreamChunk> { const chunks: Array<StreamChunk> = [] for (const clientTool of clientRequests) { chunks.push({ type: 'CUSTOM', timestamp: Date.now(), model: finishEvent.model, name: 'tool-input-available', value: { toolCallId: clientTool.toolCallId, toolName: clientTool.toolName, input: clientTool.input, }, }) } return chunks } private buildToolResultChunks( results: Array<ToolResult>, finishEvent: RunFinishedEvent, argsMap?: Map<string, string>, ): Array<StreamChunk> { const chunks: Array<StreamChunk> = [] for (const result of results) { const content = JSON.stringify(result.result) // Emit TOOL_CALL_START + TOOL_CALL_ARGS before TOOL_CALL_END so that // the client can reconstruct the full tool call during continuations. if (argsMap) { chunks.push({ type: 'TOOL_CALL_START', timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, toolName: result.toolName, }) const args = argsMap.get(result.toolCallId) ?? '{}' chunks.push({ type: 'TOOL_CALL_ARGS', timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, delta: args, args, }) } chunks.push({ type: 'TOOL_CALL_END', timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, toolName: result.toolName, result: content, }) this.messages = [ ...this.messages, { role: 'tool', content, toolCallId: result.toolCallId, }, ] } return chunks } private getPendingToolCallsFromMessages(): Array<ToolCall> { // Build a set of completed tool IDs, but exclude tools with pendingExecution marker // (these are approved tools that still need to execute) const completedToolIds = new Set<string>() for (const message of this.messages) { if (message.role === 'tool' && message.toolCallId) { // Check if this is an approval response with pendingExecution marker let hasPendingExecution = false if (typeof message.content === 'string') { try { const parsed = JSON.parse(message.content) if (parsed.pendingExecution === true) { hasPendingExecution = true } } catch { // Not JSON, treat as regular tool result } } // Only mark as complete if NOT pending execution if (!hasPendingExecution) { completedToolIds.add(message.toolCallId) } } } const pending: Array<ToolCall> = [] for (const message of this.messages) { if (message.role === 'assistant' && message.toolCalls) { for (const toolCall of message.toolCalls) { if (!completedToolIds.has(toolCall.id)) { pending.push(toolCall) } } } } return pending } private createSyntheticFinishedEvent(): RunFinishedEvent { return { type: 'RUN_FINISHED', runId: this.createId('pending'), model: this.params.model, timestamp: Date.now(), finishReason: 'tool_calls', } } private shouldContinue(): boolean { if (this.cyclePhase === 'executeToolCalls') { return true } return ( this.loopStrategy({ iterationCount: this.iterationCount, messages: this.messages, finishReason: this.lastFinishReason, }) && this.toolPhase === 'continue' ) } private isAborted(): boolean { return !!this.effectiveSignal?.aborted } private isMiddlewareAborted(): boolean { return !!this.middlewareAbortController?.signal.aborted } private isCancelled(): boolean { return this.isAborted() || this.isMiddlewareAborted() } private buildMiddlewareConfig(): ChatMiddlewareConfig { return { messages: this.messages, systemPrompts: [...this.systemPrompts], tools: [...this.tools], temperature: this.params.temperature, topP: this.params.topP, maxTokens: this.params.maxTokens, metadata: this.params.metadata, modelOptions: this.params.modelOptions, } } private applyMiddlewareConfig(config: ChatMiddlewareConfig): void { this.messages = config.messages this.systemPrompts = config.systemPrompts this.tools = config.tools this.params = { ...this.params, temperature: config.temperature, topP: config.topP, maxTokens: config.maxTokens, metadata: config.metadata, modelOptions: config.modelOptions, } // Sync context fields that depend on config this.middlewareCtx.messages = this.messages this.middlewareCtx.systemPrompts = this.systemPrompts this.middlewareCtx.hasTools = this.tools.length > 0 this.middlewareCtx.toolNames = this.tools.map((t) => t.name) this.middlewareCtx.modelOptions = config.modelOptions } private setToolPhase(phase: ToolPhaseResult): void { this.toolPhase = phase } /** * Drain an executeToolCalls async generator, yielding any CustomEvent chunks * and returning the final ExecuteToolCallsResult. */ private async *drainToolCallGenerator( generator: AsyncGenerator< CustomEvent, { results: Array<ToolResult> needsApproval: Array<ApprovalRequest> needsClientExecution: Array<ClientToolRequest> }, void >, ): AsyncGenerator< StreamChunk, { results: Array<ToolResult> needsApproval: Array<ApprovalRequest> needsClientExecution: Array<ClientToolRequest> }, void > { let next = await generator.next() while (!next.done) { yield next.value next = await generator.next() } return next.value } private createCustomEventChunk( eventName: string, value: Record<string, any>, ): CustomEvent { return { type: 'CUSTOM', timestamp: Date.now(), model: this.params.model, name: eventName, value, } } private createId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } } // =========================== // Activity Implementation // =========================== /** * Text activity - handles agentic text generation, one-shot text generation, and agentic structured output. * * This activity supports four modes: * 1. **Streaming agentic text**: Stream responses with automatic tool execution * 2. **Streaming one-shot text**: Simple streaming request/response without tools * 3. **Non-streaming text**: Returns collected text as a string (stream: false) * 4. **Agentic structured output**: Run tools, then return structured data * * @example Full agentic text (streaming with tools) * ```ts * import { chat } from '@tanstack/ai' * import { openaiText } from '@tanstack/ai-openai' * * for await (const chunk of chat({ * adapter: openaiText('gpt-4o'), * messages: [{ role: 'user', content: 'What is the weather?' }], * tools: [weatherTool] * })) { * if (chunk.type === 'content') { * console.log(chunk.delta) * } * } * ``` * * @example One-shot text (streaming without tools) * ```ts * for await (const chunk of chat({ * adapter: openaiText('gpt-4o'), * messages: [{ role: 'user', content: 'Hello!' }] * })) { * console.log(chunk) * } * ``` * * @example Non-streaming text (stream: false) * ```ts * const text = await chat({ * adapter: openaiText('gpt-4o'), * messages: [{ role: 'user', content: 'Hello!' }], * stream: false * }) * // text is a string with the full response * ``` * * @example Agentic structured output (tools + structured response) * ```ts * import { z } from 'zod' * * const result = await chat({ * adapter: openaiText('gpt-4o'), * messages: [{ role: 'user', content: 'Research and summarize the topic' }], * tools: [researchTool, analyzeTool], * outputSchema: z.object({ * summary: z.string(), * keyPoints: z.array(z.string()) * }) * }) * // result is { summary: string, keyPoints: string[] } * ``` */ export function chat< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, >( options: TextActivityOptions<TAdapter, TSchema, TStream>, ): TextActivityResult<TSchema, TStream> { const { outputSchema, stream } = options // If outputSchema is provided, run agentic structured output if (outputSchema) { return runAgenticStructuredOutput( options as unknown as TextActivityOptions< AnyTextAdapter, SchemaInput, boolean >, ) as TextActivityResult<TSchema, TStream> } // If stream is explicitly false, run non-streaming text if (stream === false) { return runNonStreamingText( options as unknown as TextActivityOptions< AnyTextAdapter, undefined, false >, ) as TextActivityResult<TSchema, TStream> } // Otherwise, run streaming text (default) return runStreamingText( options as unknown as TextActivityOptions<AnyTextAdapter, undefined, true>, ) as TextActivityResult<TSchema, TStream> } /** * Run streaming text (agentic or one-shot depending on tools) */ async function* runStreamingText( options: TextActivityOptions<AnyTextAdapter, undefined, true>, ): AsyncIterable<StreamChunk> { const { adapter, middleware, context, ...textOptions } = options const model = adapter.model const engine = new TextEngine({ adapter, params: { ...textOptions, model } as TextOptions< Record<string, any>, Record<string, any> >, middleware, context, }) for await (const chunk of engine.run()) { yield chunk } } /** * Run non-streaming text - collects all content and returns as a string. * Runs the full agentic loop (if tools are provided) but returns collected text. */ function runNonStreamingText( options: TextActivityOptions<AnyTextAdapter, undefined, false>, ): Promise<string> { // Run the streaming text and collect all text using streamToText const stream = runStreamingText( options as unknown as TextActivityOptions<AnyTextAdapter, undefined, true>, ) return streamToText(stream) } /** * Run agentic structured output: * 1. Execute the full agentic loop (with tools) * 2. Once complete, call adapter.structuredOutput with the conversation context * 3. Validate and return the structured result */ async function runAgenticStructuredOutput<TSchema extends SchemaInput>( options: TextActivityOptions<AnyTextAdapter, TSchema, boolean>, ): Promise<InferSchemaType<TSchema>> { const { adapter, outputSchema, middleware, context, ...textOptions } = options const model = adapter.model if (!outputSchema) { throw new Error('outputSchema is required for structured output') } // Create the engine and run the agentic loop const engine = new TextEngine({ adapter, params: { ...textOptions, model } as TextOptions< Record<string, unknown>, Record<string, unknown> >, middleware, context, }) // Consume the stream to run the agentic loop for await (const _chunk of engine.run()) { // Just consume the stream to execute the agentic loop } // Get the final messages from the engine (includes tool results) const finalMessages = engine.getMessages() // Build text options for structured output, excluding tools since // the agentic loop is complete and we only need the final response const { tools: _tools, agentLoopStrategy: _als, ...structuredTextOptions } = textOptions // Convert the schema to JSON Schema before passing to the adapter const jsonSchema = convertSchemaToJsonSchema(outputSchema) if (!jsonSchema) { throw new Error('Failed to convert output schema to JSON Schema') } // Call the adapter's structured output method with the conversation context // The adapter receives JSON Schema and can apply vendor-specific patches const result = await adapter.structuredOutput({ chatOptions: { ...structuredTextOptions, model, messages: finalMessages, }, outputSchema: jsonSchema, }) // Validate the result against the schema if it's a Standard Schema if (isStandardSchema(outputSchema)) { return parseWithStandardSchema<InferSchemaType<TSchema>>( outputSchema, result.data, ) } // For plain JSON Schema, return the data as-is return result.data as InferSchemaType<TSchema> } // Re-export adapter types export type { TextAdapter, TextAdapterConfig, StructuredOutputOptions, StructuredOutputResult, } from './adapter' export { BaseTextAdapter } from './adapter'