UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

806 lines (776 loc) 28.4 kB
import { aiEventClient } from '@tanstack/ai-event-client' import type { AgentLoopState, StreamChunk } from '../../../types' import type { InternalLogger } from '../../../logger/internal-logger' import type { AbortInfo, AfterToolCallInfo, BeforeToolCallDecision, ChatMiddleware, ChatMiddlewareConfig, ChatMiddlewareContext, ErrorInfo, FinishInfo, IterationInfo, SandboxFileHookEvent, StructuredOutputMiddlewareConfig, ToolCallHookContext, ToolPhaseCompleteInfo, UsageInfo, } from './types' /** One middleware's terminal-hook throw, captured instead of propagated. */ interface HookFailure { middleware: string error: unknown } /** Check if a middleware should be skipped for instrumentation events. */ function shouldSkipInstrumentation(mw: ChatMiddleware<any>): boolean { return mw.name === 'devtools' || mw.name === 'strip-to-spec' } /** Build the base context for middleware instrumentation events. */ function instrumentCtx(ctx: ChatMiddlewareContext<any>) { return { requestId: ctx.requestId, streamId: ctx.streamId, clientId: ctx.threadId, timestamp: Date.now(), } } /** * Internal middleware runner that manages composed execution of middleware hooks. * Created once per chat() invocation. */ export class MiddlewareRunner<TContext = unknown> { private readonly middlewares: ReadonlyArray<ChatMiddleware<TContext>> private readonly logger: InternalLogger constructor( middlewares: ReadonlyArray<ChatMiddleware<TContext>>, logger: InternalLogger, ) { this.middlewares = middlewares this.logger = logger } get hasMiddleware(): boolean { return this.middlewares.length > 0 } /** * Pipe config through all middleware onConfig hooks in order. * Each middleware receives the merged config from previous middleware. * Partial returns are shallow-merged with the current config. */ async runOnConfig( ctx: ChatMiddlewareContext<TContext>, config: ChatMiddlewareConfig, ): Promise<ChatMiddlewareConfig> { let current = config for (const mw of this.middlewares) { if (mw.onConfig) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const result = await mw.onConfig(ctx, current) const hasTransform = result !== undefined && result !== null if (hasTransform) { current = { ...current, ...result } if (!skip) { this.logger.config( `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`, { middleware: mw.name ?? 'unnamed', changes: result, }, ) } } if (!skip) { const base = instrumentCtx(ctx) aiEventClient.emit('middleware:hook:executed', { ...base, middlewareName: mw.name || 'unnamed', hookName: 'onConfig', iteration: ctx.iteration, duration: Date.now() - start, hasTransform, }) if (hasTransform) { aiEventClient.emit('middleware:config:transformed', { ...base, middlewareName: mw.name || 'unnamed', iteration: ctx.iteration, changes: result, }) } } } } return current } /** * Pipe config through all middleware onStructuredOutputConfig hooks in order. * Each middleware receives the merged config from previous middleware. * Partial returns are shallow-merged with the current config. * * Called once at the structured-output boundary, before runOnConfig at the * same boundary (which receives a ChatMiddlewareConfig view, no outputSchema). */ async runOnStructuredOutputConfig( ctx: ChatMiddlewareContext<TContext>, config: StructuredOutputMiddlewareConfig, ): Promise<StructuredOutputMiddlewareConfig> { let current = config for (const mw of this.middlewares) { if (mw.onStructuredOutputConfig) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const result = await mw.onStructuredOutputConfig(ctx, current) const hasTransform = result !== undefined && result !== null if (hasTransform) { current = { ...current, ...result } if (!skip) { this.logger.config( `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`, { middleware: mw.name ?? 'unnamed', changes: result, }, ) } } if (!skip) { const base = instrumentCtx(ctx) aiEventClient.emit('middleware:hook:executed', { ...base, middlewareName: mw.name || 'unnamed', hookName: 'onStructuredOutputConfig', iteration: ctx.iteration, duration: Date.now() - start, hasTransform, }) if (hasTransform) { aiEventClient.emit('middleware:config:transformed', { ...base, middlewareName: mw.name || 'unnamed', iteration: ctx.iteration, // `result` is `Partial<StructuredOutputMiddlewareConfig>` — // Object.fromEntries(Object.entries(result)) yields the // structural `Record<string, unknown>` the event emitter wants // without an `as` cast. changes: Object.fromEntries(Object.entries(result)), }) } } } } return current } /** * Run all `setup` hooks in array order, then assert every declared `provides` * capability was actually provided. Wires the last-wins duplicate-provide * warning into the registry. Runs before init `onConfig`. * * Takes the full `ChatMiddlewareContext` — the same stable context the engine * threads through every other hook — because it both forwards `ctx` to each * `setup` hook and emits instrumentation events from it. */ async runSetup(ctx: ChatMiddlewareContext<TContext>): Promise<void> { ctx.capabilities.setOnDuplicate((name) => { this.logger.warn( `capability "${name}" was provided more than once; last provider wins`, { capability: name }, ) }) for (const mw of this.middlewares) { if (mw.setup) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() await mw.setup(ctx) if (!skip) { this.logger.middleware( `hook=setup middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'setup' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'setup', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } for (const mw of this.middlewares) { for (const handle of mw.provides ?? []) { if (!ctx.capabilities.has(handle)) { throw new Error( `Middleware "${mw.name ?? 'unnamed'}" declares it provides ` + `"${handle.capabilityName}" but never called provide() in setup().`, ) } } } } /** * Call onStart on all middleware in order. */ async runOnStart(ctx: ChatMiddlewareContext<TContext>): Promise<void> { for (const mw of this.middlewares) { if (mw.onStart) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() await mw.onStart(ctx) if (!skip) { this.logger.middleware( `hook=onStart middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onStart' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onStart', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } /** * Pipe a single chunk through all middleware onChunk hooks in order. * Returns the resulting chunks (0..N) to yield to the consumer. * * - void: pass through unchanged * - chunk: replace with this chunk * - chunk[]: expand to multiple chunks * - null: drop the chunk entirely */ async runOnChunk( ctx: ChatMiddlewareContext<TContext>, chunk: StreamChunk, ): Promise<Array<StreamChunk>> { let chunks: Array<StreamChunk> = [chunk] for (const mw of this.middlewares) { if (!mw.onChunk) continue const skip = shouldSkipInstrumentation(mw) const nextChunks: Array<StreamChunk> = [] for (const c of chunks) { // Cast: @ag-ui/core Zod passthrough types prevent direct `.type` access const chunkType = c.type if (!skip) { this.logger.middleware( `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType}`, { middleware: mw.name ?? 'unnamed', hook: 'onChunk', in: c }, ) } const result = await mw.onChunk(ctx, c) if (result === null) { // Drop this chunk if (!skip) { this.logger.middleware( `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=<dropped>`, { middleware: mw.name ?? 'unnamed', hook: 'onChunk', dropped: true, }, ) aiEventClient.emit('middleware:chunk:transformed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', originalChunkType: chunkType, resultCount: 0, wasDropped: true, }) } continue } else if (result === undefined) { // Pass through — no instrumentation for pass-throughs nextChunks.push(c) } else if (Array.isArray(result)) { // Expand nextChunks.push(...result) if (!skip) { this.logger.middleware( `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=[${result.map((r: StreamChunk) => r.type).join(',')}]`, { middleware: mw.name ?? 'unnamed', hook: 'onChunk', in: c, out: result, }, ) aiEventClient.emit('middleware:chunk:transformed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', originalChunkType: chunkType, resultCount: result.length, wasDropped: false, }) } } else { // Replace nextChunks.push(result) if (!skip) { this.logger.middleware( `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=${result.type}`, { middleware: mw.name ?? 'unnamed', hook: 'onChunk', in: c, out: result, }, ) aiEventClient.emit('middleware:chunk:transformed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', originalChunkType: chunkType, resultCount: 1, wasDropped: false, }) } } } chunks = nextChunks } return chunks } /** * Dispatch a sandbox file event to every middleware's `sandbox` hooks, in * array order: the catch-all `onFile` then the type-specific hook. Errors are * logged and swallowed so one bad hook can't break the run. */ async runSandboxFile( ctx: ChatMiddlewareContext<TContext>, event: SandboxFileHookEvent, ): Promise<void> { const typed = ( { create: 'onFileCreate', change: 'onFileChange', delete: 'onFileDelete', } as const )[event.type] for (const mw of this.middlewares) { const hooks = mw.sandbox if (!hooks) continue for (const fn of [hooks.onFile, hooks[typed]]) { if (!fn) continue try { await fn(ctx, event) } catch (error) { this.logger.sandbox( `hook=${typed} middleware=${mw.name ?? 'unnamed'} threw`, { middleware: mw.name ?? 'unnamed', error }, ) } } } } /** * Run onBeforeToolCall through middleware in order. * Returns the first non-void decision, or undefined to continue normally. */ async runOnBeforeToolCall( ctx: ChatMiddlewareContext<TContext>, hookCtx: ToolCallHookContext, ): Promise<BeforeToolCallDecision> { for (const mw of this.middlewares) { if (mw.onBeforeToolCall) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const decision = await mw.onBeforeToolCall(ctx, hookCtx) const hasTransform = decision !== undefined && decision !== null if (!skip) { this.logger.middleware( `hook=onBeforeToolCall middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onBeforeToolCall' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onBeforeToolCall', iteration: ctx.iteration, duration: Date.now() - start, hasTransform, }) } if (hasTransform) { return decision } } } return undefined } /** * Run onAfterToolCall on all middleware in order. */ async runOnAfterToolCall( ctx: ChatMiddlewareContext<TContext>, info: AfterToolCallInfo, ): Promise<void> { for (const mw of this.middlewares) { if (mw.onAfterToolCall) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() await mw.onAfterToolCall(ctx, info) if (!skip) { this.logger.middleware( `hook=onAfterToolCall middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onAfterToolCall' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onAfterToolCall', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } /** * Run onUsage on all middleware in order. */ async runOnUsage( ctx: ChatMiddlewareContext<TContext>, usage: UsageInfo, ): Promise<void> { for (const mw of this.middlewares) { if (mw.onUsage) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() await mw.onUsage(ctx, usage) if (!skip) { this.logger.middleware( `hook=onUsage middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onUsage' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onUsage', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } /** * Await ONE terminal hook and RETURN its throw instead of letting it escape * the caller's loop, logging it on the `errors` channel first so the failure * is never invisible. `undefined` means the hook completed. * * Capturing (rather than swallowing at this level) is what lets isolation and * reporting coexist: every caller gives every middleware its turn, and then * each decides on its own whether the collected failures are worth telling the * caller about. See {@link runOnFinish} vs {@link runOnAbort} / * {@link runOnError}. */ private async captureTerminalHook( mw: ChatMiddleware<TContext>, hookName: 'onFinish' | 'onAbort' | 'onError', invoke: () => void | Promise<void>, ): Promise<HookFailure | undefined> { try { await invoke() return undefined } catch (error) { this.logger.errors(`middleware ${hookName} hook failed`, { middleware: mw.name ?? 'unnamed', hook: hookName, error, }) return { middleware: mw.name ?? 'unnamed', error } } } /** * Run onFinish on all middleware in order. * * ISOLATED **and** REPORTED. `onFinish` is the only terminal fan-out on the * SUCCESS path, and it is where `withPersistence.onFinish` writes the * assistant turn through the store. So the two properties are needed together * and neither may be traded for the other: * * - ISOLATION: every middleware's hook runs even if an earlier one threw, so a * transient store error cannot skip a later middleware's own bookkeeping. * Each failure is captured by {@link captureTerminalHook}, not propagated * mid-loop. * - REPORTING: after the loop, the failures are rethrown. `chat()`'s catch * treats what we throw as a genuine error (it is not a * `MiddlewareAbortError`, and `structuralInterruptFailure` does not match * it) and rethrows it out of the generator. * * What that rethrow can and cannot achieve depends on the transport, because * this fan-out is awaited AFTER the adapter's `RUN_FINISHED` has already been * yielded (`chat()` yields terminal chunks while streaming, then awaits this * hook on its way out). The success terminal is therefore already gone; the * rethrow can only append to what the consumer saw, never retract it: * * - NON-DURABLE transport: the throw escapes the generator mid-response, and * the SSE / HTTP-stream encoder turns it into a TRAILING `RUN_ERROR` on the * wire carrying the store's own message and `code`. `ai-client` surfaces * that as an error status, so the user is not told the turn was saved when * it was not. * - DURABLE transport: the throw reaches the durability sink instead. The * terminal was already persisted AND forwarded, so the sink deliberately * does NOT append a second, contradictory terminal, and `terminalForwarded` * (see `stream-to-response.ts`) suppresses the rethrow to the live consumer. * The `RUN_FINISHED` stands and the failure is RECORDED SERVER-SIDE on the * sink's `errors` channel. That is the intended outcome, not a gap: the save * failed, not the run — the consumer did receive the complete stream, so * telling it the run errored would be the lie. What the rethrow buys here is * that the sink sees the failure at all; while this loop swallowed, the only * trace anywhere was {@link captureTerminalHook}'s log line. * * Either way, swallowing is the one option ruled out: a failed * `messages.append` would otherwise leave a `completed` run record with the * assistant turn missing from storage and nothing beyond a middleware log * line, and the client would go on to send a history the server has no record * of. * * A single failure is rethrown AS-IS so the store's own error — its message, * `cause`, `code` and `instanceof` identity — is what reaches the caller and * the wire; wrapping the common case would bury it. Two or more become an * `AggregateError` (never a `MiddlewareAbortError`, so it cannot be mistaken * for an abort) rather than picking a winner and dropping the rest. */ async runOnFinish( ctx: ChatMiddlewareContext<TContext>, info: FinishInfo, ): Promise<void> { const failures: Array<HookFailure> = [] let firstFailure: HookFailure | undefined for (const mw of this.middlewares) { const hook = mw.onFinish if (hook) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const failure = await this.captureTerminalHook(mw, 'onFinish', () => hook.call(mw, ctx, info), ) if (failure !== undefined) { firstFailure ??= failure failures.push(failure) continue } if (!skip) { this.logger.middleware( `hook=onFinish middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onFinish' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onFinish', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } if (firstFailure !== undefined) { throw failures.length === 1 ? firstFailure.error : new AggregateError( failures.map((f) => f.error), `${failures.length} middleware onFinish hooks failed: ` + failures.map((f) => f.middleware).join(', '), ) } } /** * Run onAbort on all middleware in order. * * ISOLATED and DELIBERATELY SWALLOWED. `onAbort` is a pure teardown fan-out * released from `chat()`'s `finally`, on a path where the outcome is already * decided: the run stopped, and the caller is being told why. A throw here has * nothing better to report than the abort reason it would DISPLACE — the * `finally` would surface a flaky store's error in place of "client * disconnected" — so failures are logged on the `errors` channel and go no * further. That is not a silent failure; it is refusing to let teardown * rewrite an outcome it did not produce. * * Isolation matters independently: these hooks release PER-MIDDLEWARE * resources (`withSandbox.onAbort` detaches or destroys the sandbox and stamps * `detachedSince`; `withPersistence.onAbort` records the run status through the * store), so an unguarded loop turns one transient store error into a * permanently leaked sandbox for every middleware ordered after it. */ async runOnAbort( ctx: ChatMiddlewareContext<TContext>, info: AbortInfo, ): Promise<void> { for (const mw of this.middlewares) { const hook = mw.onAbort if (hook) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const failure = await this.captureTerminalHook(mw, 'onAbort', () => hook.call(mw, ctx, info), ) if (failure === undefined && !skip) { this.logger.middleware( `hook=onAbort middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onAbort' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onAbort', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } /** * Run onError on all middleware in order. * * ISOLATED and DELIBERATELY SWALLOWED, for the same reason as * {@link runOnAbort} and NOT merely because it is teardown: the run has * already failed, `info.error` IS that failure, and `chat()` rethrows it to the * caller the moment this fan-out returns. A propagated hook throw could only * REPLACE the run's real error with a teardown artifact — strictly less * information for the caller, who is already learning the run failed. Reporting * would buy nothing and cost the diagnosis, so failures are logged on the * `errors` channel and stop there. * * Contrast {@link runOnFinish}, where nothing else is telling the caller * anything is wrong — which is why that one reports. */ async runOnError( ctx: ChatMiddlewareContext<TContext>, info: ErrorInfo, ): Promise<void> { for (const mw of this.middlewares) { const hook = mw.onError if (hook) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const failure = await this.captureTerminalHook(mw, 'onError', () => hook.call(mw, ctx, info), ) if (failure === undefined && !skip) { this.logger.middleware( `hook=onError middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onError' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onError', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } /** * Run onIteration on all middleware in order. * Called at the start of each agent loop iteration. */ async runOnIteration( ctx: ChatMiddlewareContext<TContext>, info: IterationInfo, ): Promise<void> { for (const mw of this.middlewares) { if (mw.onIteration) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() await mw.onIteration(ctx, info) if (!skip) { this.logger.middleware( `hook=onIteration middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onIteration' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onIteration', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } /** * Run onShouldContinue through middleware in order (AND semantics). * Any explicit `false` stops further iterations; `true` / void / undefined pass. * Called after `agentLoopStrategy` has already approved continuation. */ async runOnShouldContinue( ctx: ChatMiddlewareContext<TContext>, state: AgentLoopState, ): Promise<boolean> { for (const mw of this.middlewares) { if (mw.onShouldContinue) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() const result = await mw.onShouldContinue(ctx, state) if (!skip) { this.logger.middleware( `hook=onShouldContinue middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onShouldContinue', result, }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onShouldContinue', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: result === false, }) } if (result === false) { return false } } } return true } /** * Run onToolPhaseComplete on all middleware in order. * Called after all tool calls in an iteration have been processed. */ async runOnToolPhaseComplete( ctx: ChatMiddlewareContext<TContext>, info: ToolPhaseCompleteInfo, ): Promise<void> { for (const mw of this.middlewares) { if (mw.onToolPhaseComplete) { const skip = shouldSkipInstrumentation(mw) const start = Date.now() await mw.onToolPhaseComplete(ctx, info) if (!skip) { this.logger.middleware( `hook=onToolPhaseComplete middleware=${mw.name ?? 'unnamed'}`, { middleware: mw.name ?? 'unnamed', hook: 'onToolPhaseComplete' }, ) aiEventClient.emit('middleware:hook:executed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', hookName: 'onToolPhaseComplete', iteration: ctx.iteration, duration: Date.now() - start, hasTransform: false, }) } } } } }