From 4aa6eb24a6758abf62a6469caa210ab1bde67207 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:25:07 +0200 Subject: [PATCH 01/50] feat(ai-orchestration): scaffold package --- .../typescript/ai-orchestration/README.md | 5 ++ .../ai-orchestration/eslint.config.js | 9 ++++ .../typescript/ai-orchestration/package.json | 48 +++++++++++++++++++ .../typescript/ai-orchestration/src/index.ts | 2 + .../typescript/ai-orchestration/tsconfig.json | 8 ++++ .../ai-orchestration/vite.config.ts | 36 ++++++++++++++ 6 files changed, 108 insertions(+) create mode 100644 packages/typescript/ai-orchestration/README.md create mode 100644 packages/typescript/ai-orchestration/eslint.config.js create mode 100644 packages/typescript/ai-orchestration/package.json create mode 100644 packages/typescript/ai-orchestration/src/index.ts create mode 100644 packages/typescript/ai-orchestration/tsconfig.json create mode 100644 packages/typescript/ai-orchestration/vite.config.ts diff --git a/packages/typescript/ai-orchestration/README.md b/packages/typescript/ai-orchestration/README.md new file mode 100644 index 000000000..278acb2d2 --- /dev/null +++ b/packages/typescript/ai-orchestration/README.md @@ -0,0 +1,5 @@ +# @tanstack/ai-orchestration + +Generator-based workflows and orchestrators for TanStack AI. Define typed agents that run via `chat()`, compose them into workflows with `yield*`, pause for approvals, and stream state through native AG-UI events. + +> Status: v0 prototype. diff --git a/packages/typescript/ai-orchestration/eslint.config.js b/packages/typescript/ai-orchestration/eslint.config.js new file mode 100644 index 000000000..c3d273991 --- /dev/null +++ b/packages/typescript/ai-orchestration/eslint.config.js @@ -0,0 +1,9 @@ +import rootConfig from '../../../eslint.config.js' + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + ...rootConfig, + { + rules: {}, + }, +] diff --git a/packages/typescript/ai-orchestration/package.json b/packages/typescript/ai-orchestration/package.json new file mode 100644 index 000000000..54d278cfa --- /dev/null +++ b/packages/typescript/ai-orchestration/package.json @@ -0,0 +1,48 @@ +{ + "name": "@tanstack/ai-orchestration", + "version": "0.0.1", + "description": "Generator-based workflows and orchestrators for TanStack AI", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-orchestration" + }, + "keywords": ["ai", "tanstack", "workflow", "orchestration", "agents"], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": ["dist", "src"], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:coverage": "vitest run --coverage", + "test:eslint": "eslint ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/ai-event-client": "workspace:*", + "fast-json-patch": "^3.1.1" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^" + }, + "devDependencies": { + "@standard-schema/spec": "^1.1.0", + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7", + "zod": "^4.2.0" + } +} diff --git a/packages/typescript/ai-orchestration/src/index.ts b/packages/typescript/ai-orchestration/src/index.ts new file mode 100644 index 000000000..fff9e46a2 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/index.ts @@ -0,0 +1,2 @@ +// Public API surface — populated by later tasks +export {} diff --git a/packages/typescript/ai-orchestration/tsconfig.json b/packages/typescript/ai-orchestration/tsconfig.json new file mode 100644 index 000000000..3e93ac127 --- /dev/null +++ b/packages/typescript/ai-orchestration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "vite.config.ts"], + "exclude": ["node_modules", "dist", "**/*.config.ts", "eslint.config.js"] +} diff --git a/packages/typescript/ai-orchestration/vite.config.ts b/packages/typescript/ai-orchestration/vite.config.ts new file mode 100644 index 000000000..77bcc2e60 --- /dev/null +++ b/packages/typescript/ai-orchestration/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) From 06e4f549a944856f2f874948d36441b93f9fdc97 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:28:09 +0200 Subject: [PATCH 02/50] feat(ai-orchestration): add core public types --- .../typescript/ai-orchestration/src/types.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/types.ts diff --git a/packages/typescript/ai-orchestration/src/types.ts b/packages/typescript/ai-orchestration/src/types.ts new file mode 100644 index 000000000..b57d430d6 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/types.ts @@ -0,0 +1,191 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { StreamChunk } from '@tanstack/ai' + +// ========================================== +// Standard Schema helpers +// ========================================== + +export type SchemaInput = StandardSchemaV1 +export type InferSchema = T extends StandardSchemaV1 + ? Out + : never + +// ========================================== +// Agent +// ========================================== + +export type AgentRunArgs = { + input: TInput + emit: EmitFn + signal: AbortSignal +} + +export type AgentRunResult = + | AsyncIterable + | Promise + | { stream: AsyncIterable; output: Promise } + +export interface AgentDefinition< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TName extends string = string, +> { + __kind: 'agent' + name: TName + description?: string + inputSchema?: TInputSchema + outputSchema?: TOutputSchema + run: ( + args: AgentRunArgs< + TInputSchema extends SchemaInput ? InferSchema : unknown + >, + ) => AgentRunResult< + TOutputSchema extends SchemaInput ? InferSchema : unknown + > +} + +export type AnyAgentDefinition = AgentDefinition + +// ========================================== +// Workflow +// ========================================== + +export type WorkflowRunArgs = { + input: TInput + state: TState + agents: BoundAgents + emit: EmitFn + signal: AbortSignal +} + +export type AgentMap = Record + +export type BoundAgents = { + [K in keyof TAgents]: TAgents[K] extends AgentDefinition< + infer TIn, + infer TOut, + any + > + ? ( + input: TIn extends SchemaInput ? InferSchema : unknown, + ) => StepGenerator< + TOut extends SchemaInput ? InferSchema : unknown + > + : TAgents[K] extends WorkflowDefinition< + infer WIn, + infer WOut, + any, + any + > + ? ( + input: WIn extends SchemaInput ? InferSchema : unknown, + ) => StepGenerator : unknown> + : never +} + +export interface WorkflowDefinition< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TStateSchema extends SchemaInput | undefined, + TAgents extends AgentMap, +> { + __kind: 'workflow' + name: string + description?: string + inputSchema?: TInputSchema + outputSchema?: TOutputSchema + stateSchema?: TStateSchema + agents: TAgents + initialize?: (args: { + input: TInputSchema extends SchemaInput + ? InferSchema + : unknown + }) => TStateSchema extends SchemaInput + ? Partial> + : Record + run: ( + args: WorkflowRunArgs< + TInputSchema extends SchemaInput ? InferSchema : unknown, + TStateSchema extends SchemaInput ? InferSchema : Record, + TAgents + >, + ) => AsyncGenerator< + StepDescriptor, + TOutputSchema extends SchemaInput ? InferSchema : unknown, + unknown + > +} + +export type AnyWorkflowDefinition = WorkflowDefinition + +// ========================================== +// Step descriptors +// ========================================== + +export type StepDescriptor = + | { kind: 'agent'; name: string; input: unknown; agent: AnyAgentDefinition } + | { kind: 'nested-workflow'; name: string; input: unknown; workflow: AnyWorkflowDefinition } + | { kind: 'approval'; title: string; description?: string } + +export type StepGenerator = Generator + +// ========================================== +// Approval result +// ========================================== + +export interface ApprovalResult { + approved: boolean + approvalId: string +} + +// ========================================== +// Emit +// ========================================== + +export type EmitFn = (name: string, value: Record) => void + +// ========================================== +// Run state +// ========================================== + +export type RunStatus = + | 'running' + | 'paused' + | 'finished' + | 'error' + | 'aborted' + +export interface RunState { + runId: string + status: RunStatus + workflowName: string + input: TInput + state: TState + output?: TOutput + error?: { name: string; message: string; stack?: string } + pendingApproval?: { approvalId: string; title: string; description?: string } + createdAt: number + updatedAt: number +} + +// ========================================== +// RunStore +// ========================================== + +export type DeleteReason = 'finished' | 'error' | 'aborted' + +export interface RunStore { + get(runId: string): Promise + set(runId: string, state: RunState): Promise + delete(runId: string, reason: DeleteReason): Promise +} + +// ========================================== +// Engine-internal: live (non-serializable) run handle +// ========================================== +export interface LiveRun { + runState: RunState + generator: AsyncGenerator + abortController: AbortController + approvalResolver?: (result: ApprovalResult) => void +} From 826920c4fcb079327ca368c740e4fadedbd084f0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:28:57 +0200 Subject: [PATCH 03/50] feat(ai-orchestration): add in-memory RunStore --- .../src/run-store/in-memory.ts | 61 +++++++++++++++++++ .../ai-orchestration/src/run-store/index.ts | 3 + 2 files changed, 64 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/run-store/in-memory.ts create mode 100644 packages/typescript/ai-orchestration/src/run-store/index.ts diff --git a/packages/typescript/ai-orchestration/src/run-store/in-memory.ts b/packages/typescript/ai-orchestration/src/run-store/in-memory.ts new file mode 100644 index 000000000..4741a7f6f --- /dev/null +++ b/packages/typescript/ai-orchestration/src/run-store/in-memory.ts @@ -0,0 +1,61 @@ +import type { RunStore, RunState, LiveRun } from '../types' + +export interface InMemoryRunStoreOptions { + /** TTL in milliseconds. Default 1 hour. */ + ttl?: number +} + +/** + * In-memory RunStore. Holds RunState plus the live generator handle so the + * engine can resume directly without replaying a step log. Suitable for + * single-process prototypes. + */ +export interface InMemoryRunStore extends RunStore { + /** Engine-only: stash the live generator handle alongside the run state. */ + setLive(runId: string, live: LiveRun): void + /** Engine-only: retrieve the live generator handle. */ + getLive(runId: string): LiveRun | undefined +} + +export function inMemoryRunStore( + options: InMemoryRunStoreOptions = {}, +): InMemoryRunStore { + const ttl = options.ttl ?? 60 * 60 * 1000 + const runs = new Map() + const live = new Map() + const expirations = new Map() + + function scheduleExpiry(runId: string) { + const existing = expirations.get(runId) + if (existing) clearTimeout(existing) + const handle = setTimeout(() => { + runs.delete(runId) + live.delete(runId) + expirations.delete(runId) + }, ttl) + expirations.set(runId, handle) + } + + return { + async get(runId) { + return runs.get(runId) + }, + async set(runId, state) { + runs.set(runId, state) + scheduleExpiry(runId) + }, + async delete(runId, _reason) { + runs.delete(runId) + live.delete(runId) + const handle = expirations.get(runId) + if (handle) clearTimeout(handle) + expirations.delete(runId) + }, + setLive(runId, l) { + live.set(runId, l) + }, + getLive(runId) { + return live.get(runId) + }, + } +} diff --git a/packages/typescript/ai-orchestration/src/run-store/index.ts b/packages/typescript/ai-orchestration/src/run-store/index.ts new file mode 100644 index 000000000..c64afadd4 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/run-store/index.ts @@ -0,0 +1,3 @@ +export { inMemoryRunStore } from './in-memory' +export type { InMemoryRunStore, InMemoryRunStoreOptions } from './in-memory' +export type { RunStore, RunState, RunStatus, DeleteReason } from '../types' From 8b39b2f8fe2477531abdedb3c222fafadae7e3c8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:34:09 +0200 Subject: [PATCH 04/50] fix(ai-orchestration): align with repo lint conventions --- .../ai-orchestration/src/run-store/in-memory.ts | 16 +++++++++------- .../typescript/ai-orchestration/src/types.ts | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/typescript/ai-orchestration/src/run-store/in-memory.ts b/packages/typescript/ai-orchestration/src/run-store/in-memory.ts index 4741a7f6f..b7a963fed 100644 --- a/packages/typescript/ai-orchestration/src/run-store/in-memory.ts +++ b/packages/typescript/ai-orchestration/src/run-store/in-memory.ts @@ -1,4 +1,4 @@ -import type { RunStore, RunState, LiveRun } from '../types' +import type { LiveRun, RunState, RunStore } from '../types' export interface InMemoryRunStoreOptions { /** TTL in milliseconds. Default 1 hour. */ @@ -12,9 +12,9 @@ export interface InMemoryRunStoreOptions { */ export interface InMemoryRunStore extends RunStore { /** Engine-only: stash the live generator handle alongside the run state. */ - setLive(runId: string, live: LiveRun): void + setLive: (runId: string, live: LiveRun) => void /** Engine-only: retrieve the live generator handle. */ - getLive(runId: string): LiveRun | undefined + getLive: (runId: string) => LiveRun | undefined } export function inMemoryRunStore( @@ -37,19 +37,21 @@ export function inMemoryRunStore( } return { - async get(runId) { - return runs.get(runId) + get(runId) { + return Promise.resolve(runs.get(runId)) }, - async set(runId, state) { + set(runId, state) { runs.set(runId, state) scheduleExpiry(runId) + return Promise.resolve() }, - async delete(runId, _reason) { + delete(runId, _reason) { runs.delete(runId) live.delete(runId) const handle = expirations.get(runId) if (handle) clearTimeout(handle) expirations.delete(runId) + return Promise.resolve() }, setLive(runId, l) { live.set(runId, l) diff --git a/packages/typescript/ai-orchestration/src/types.ts b/packages/typescript/ai-orchestration/src/types.ts index b57d430d6..beb8d8049 100644 --- a/packages/typescript/ai-orchestration/src/types.ts +++ b/packages/typescript/ai-orchestration/src/types.ts @@ -175,9 +175,9 @@ export interface RunState export type DeleteReason = 'finished' | 'error' | 'aborted' export interface RunStore { - get(runId: string): Promise - set(runId: string, state: RunState): Promise - delete(runId: string, reason: DeleteReason): Promise + get: (runId: string) => Promise + set: (runId: string, state: RunState) => Promise + delete: (runId: string, reason: DeleteReason) => Promise } // ========================================== From 03f95d8e00b6265861114894c270fb58fa185249 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:43:15 +0200 Subject: [PATCH 05/50] feat(ai-orchestration): add approve, bindAgents, and retry primitives Implements yield-helpers for the workflow engine: approve() for human-in-the-loop approval steps, bindAgents() to convert agent/workflow definitions into bound step generators, and retry() (async generator) for fault-tolerant step execution with configurable backoff. --- .../src/primitives/approve.ts | 26 ++++++++ .../src/primitives/bind-agents.ts | 40 ++++++++++++ .../ai-orchestration/src/primitives/index.ts | 5 ++ .../ai-orchestration/src/primitives/retry.ts | 61 +++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/primitives/approve.ts create mode 100644 packages/typescript/ai-orchestration/src/primitives/bind-agents.ts create mode 100644 packages/typescript/ai-orchestration/src/primitives/index.ts create mode 100644 packages/typescript/ai-orchestration/src/primitives/retry.ts diff --git a/packages/typescript/ai-orchestration/src/primitives/approve.ts b/packages/typescript/ai-orchestration/src/primitives/approve.ts new file mode 100644 index 000000000..ffcc77c05 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/primitives/approve.ts @@ -0,0 +1,26 @@ +import type { ApprovalResult, StepDescriptor, StepGenerator } from '../types' + +export interface ApproveOptions { + title: string + description?: string +} + +/** + * Yieldable approval primitive. + * + * const decision = yield* approve({ title: 'Publish?' }) + * if (!decision.approved) return { ok: false } + * + * The engine pauses the run, emits an `approval-requested` custom event with + * `kind: 'workflow'`, closes the SSE, and resumes when the client replies. + */ +export function* approve(options: ApproveOptions): StepGenerator { + const descriptor: StepDescriptor = { + kind: 'approval', + title: options.title, + description: options.description, + } + // The engine returns ApprovalResult via gen.next(value). + const result = (yield descriptor) as unknown as ApprovalResult + return result +} diff --git a/packages/typescript/ai-orchestration/src/primitives/bind-agents.ts b/packages/typescript/ai-orchestration/src/primitives/bind-agents.ts new file mode 100644 index 000000000..555c115a1 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/primitives/bind-agents.ts @@ -0,0 +1,40 @@ +import type { AgentMap, BoundAgents, StepDescriptor } from '../types' + +/** + * Convert a declared `agents` map into bound, callable functions that produce + * step generators. Used by the engine to construct the `agents` argument + * passed into the user's workflow `run`. + */ +export function bindAgents( + agents: TAgents, +): BoundAgents { + const bound = {} as Record + + for (const [name, def] of Object.entries(agents)) { + if (def.__kind === 'agent') { + bound[name] = function* (input: unknown): Generator { + const descriptor: StepDescriptor = { + kind: 'agent', + name, + input, + agent: def, + } + const result = yield descriptor + return result + } + } else { + bound[name] = function* (input: unknown): Generator { + const descriptor: StepDescriptor = { + kind: 'nested-workflow', + name, + input, + workflow: def, + } + const result = yield descriptor + return result + } + } + } + + return bound as BoundAgents +} diff --git a/packages/typescript/ai-orchestration/src/primitives/index.ts b/packages/typescript/ai-orchestration/src/primitives/index.ts new file mode 100644 index 000000000..fa8d78144 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/primitives/index.ts @@ -0,0 +1,5 @@ +export { approve } from './approve' +export type { ApproveOptions } from './approve' +export { bindAgents } from './bind-agents' +export { retry } from './retry' +export type { RetryOptions } from './retry' diff --git a/packages/typescript/ai-orchestration/src/primitives/retry.ts b/packages/typescript/ai-orchestration/src/primitives/retry.ts new file mode 100644 index 000000000..a92a1c3c0 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/primitives/retry.ts @@ -0,0 +1,61 @@ +import type { StepDescriptor } from '../types' + +export interface RetryOptions { + attempts: number + backoff?: 'none' | 'linear' | 'exponential' + /** Base delay in ms. Default 100. */ + baseDelayMs?: number + /** Max delay in ms. Default 5000. */ + maxDelayMs?: number + /** Predicate — return true to retry on this error. Default: retry any. */ + retryOn?: (err: unknown, attempt: number) => boolean +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function computeDelay(opts: RetryOptions, attempt: number): number { + const base = opts.baseDelayMs ?? 100 + const max = opts.maxDelayMs ?? 5000 + switch (opts.backoff ?? 'none') { + case 'none': + return 0 + case 'linear': + return Math.min(base * attempt, max) + case 'exponential': + return Math.min(base * 2 ** (attempt - 1), max) + } +} + +/** + * Retry a yield-producing step on failure. + * + * const draft = yield* retry( + * () => agents.writer({ topic }), + * { attempts: 3, backoff: 'exponential' }, + * ) + * + * Each attempt invokes `fn()` fresh, so the underlying generator restarts. + * Returns an async generator to support delay between retries. + */ +export async function* retry( + fn: () => Generator, + options: RetryOptions, +): AsyncGenerator { + let lastErr: unknown + for (let attempt = 1; attempt <= options.attempts; attempt++) { + try { + return yield* fn() + } catch (err) { + lastErr = err + if (options.retryOn && !options.retryOn(err, attempt)) { + throw err + } + if (attempt === options.attempts) break + const ms = computeDelay(options, attempt) + if (ms > 0) await delay(ms) + } + } + throw lastErr +} From fbb1114f5a41f7bd06d677b7f3ace93c12bbbf59 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:43:26 +0200 Subject: [PATCH 06/50] feat(ai-orchestration): add state snapshot/diff and AG-UI event emit helpers Implements snapshotState/diffState using fast-json-patch for RFC 6902 JSON Patch diffs, plus emit-events helpers (runStartedEvent, stepStartedEvent, stateSnapshotEvent, approvalRequestedEvent, etc.) that produce StreamChunk values for the workflow SSE stream. --- .../src/engine/emit-events.ts | 117 ++++++++++++++++++ .../ai-orchestration/src/engine/state-diff.ts | 17 +++ pnpm-lock.yaml | 30 +++++ 3 files changed, 164 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/engine/emit-events.ts create mode 100644 packages/typescript/ai-orchestration/src/engine/state-diff.ts diff --git a/packages/typescript/ai-orchestration/src/engine/emit-events.ts b/packages/typescript/ai-orchestration/src/engine/emit-events.ts new file mode 100644 index 000000000..dedc394c7 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/engine/emit-events.ts @@ -0,0 +1,117 @@ +import type { StreamChunk } from '@tanstack/ai' +import type { Operation } from 'fast-json-patch' + +/** + * Helpers that produce native AG-UI event chunks for the workflow lifecycle. + * The engine yields these into the outer SSE stream. + */ + +export function runStartedEvent(args: { + runId: string + threadId?: string +}): StreamChunk { + return { + type: 'RUN_STARTED', + timestamp: Date.now(), + runId: args.runId, + threadId: args.threadId ?? args.runId, + } as StreamChunk +} + +export function runFinishedEvent(args: { + runId: string + threadId?: string +}): StreamChunk { + return { + type: 'RUN_FINISHED', + timestamp: Date.now(), + runId: args.runId, + threadId: args.threadId ?? args.runId, + } as StreamChunk +} + +export function runErrorEvent(args: { + runId: string + message: string + code?: string +}): StreamChunk { + return { + type: 'RUN_ERROR', + timestamp: Date.now(), + message: args.message, + code: args.code ?? 'error', + } as StreamChunk +} + +export function stepStartedEvent(args: { + stepId: string + stepName: string + stepType?: 'agent' | 'approval' | 'nested-workflow' +}): StreamChunk { + return { + type: 'STEP_STARTED', + timestamp: Date.now(), + stepName: args.stepName, + stepId: args.stepId, + stepType: args.stepType, + } as StreamChunk +} + +export function stepFinishedEvent(args: { + stepId: string + stepName: string + content?: unknown +}): StreamChunk { + return { + type: 'STEP_FINISHED', + timestamp: Date.now(), + stepName: args.stepName, + stepId: args.stepId, + content: args.content, + } as StreamChunk +} + +export function stateSnapshotEvent(args: { snapshot: unknown }): StreamChunk { + return { + type: 'STATE_SNAPSHOT', + timestamp: Date.now(), + snapshot: args.snapshot, + } as StreamChunk +} + +export function stateDeltaEvent(args: { delta: Array }): StreamChunk { + return { + type: 'STATE_DELTA', + timestamp: Date.now(), + delta: args.delta, + } as StreamChunk +} + +export function customEvent(args: { + name: string + value: Record +}): StreamChunk { + return { + type: 'CUSTOM', + timestamp: Date.now(), + name: args.name, + value: args.value, + } as StreamChunk +} + +export function approvalRequestedEvent(args: { + approvalId: string + kind: 'workflow' | 'tool' + title: string + description?: string +}): StreamChunk { + return customEvent({ + name: 'approval-requested', + value: { + approvalId: args.approvalId, + kind: args.kind, + title: args.title, + description: args.description, + }, + }) +} diff --git a/packages/typescript/ai-orchestration/src/engine/state-diff.ts b/packages/typescript/ai-orchestration/src/engine/state-diff.ts new file mode 100644 index 000000000..863dd2bbb --- /dev/null +++ b/packages/typescript/ai-orchestration/src/engine/state-diff.ts @@ -0,0 +1,17 @@ +import { compare } from 'fast-json-patch' +import type { Operation } from 'fast-json-patch' + +/** + * Snapshot a state object via structuredClone for later diffing. + */ +export function snapshotState(state: T): T { + return structuredClone(state) +} + +/** + * Produce an RFC 6902 JSON Patch from `prev` to `next`. Empty array if no + * changes. + */ +export function diffState(prev: T, next: T): Array { + return compare(prev as object, next as object) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05b781d9..428fecbff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1335,6 +1335,31 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/typescript/ai-orchestration: + dependencies: + '@tanstack/ai-event-client': + specifier: workspace:* + version: link:../ai-event-client + fast-json-patch: + specifier: ^3.1.1 + version: 3.1.1 + devDependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + vite: + specifier: ^7.2.7 + version: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/typescript/ai-preact: dependencies: '@tanstack/ai': @@ -8050,6 +8075,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-patch@3.1.1: + resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -18966,6 +18994,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-patch@3.1.1: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} From 24702c63a3b129c7f1455d52a4c576e8918be98a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:49:24 +0200 Subject: [PATCH 07/50] feat(ai-orchestration): agent invocation with three return shapes --- .../src/engine/invoke-agent.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/engine/invoke-agent.ts diff --git a/packages/typescript/ai-orchestration/src/engine/invoke-agent.ts b/packages/typescript/ai-orchestration/src/engine/invoke-agent.ts new file mode 100644 index 000000000..8f2c467cf --- /dev/null +++ b/packages/typescript/ai-orchestration/src/engine/invoke-agent.ts @@ -0,0 +1,162 @@ +import type { StreamChunk } from '@tanstack/ai' +import type { AgentRunResult, AnyAgentDefinition } from '../types' + +export interface InvokeAgentResult { + /** Stream of inner chunks to pipe to outer SSE (already filtered for inner RunStarted/Finished). */ + stream: AsyncIterable + /** Resolves with the parsed typed output. */ + output: Promise +} + +/** + * Detect which of the three shapes the agent's `run` returned and normalize + * to a `{ stream, output }` pair. + */ +export function invokeAgent( + agent: AnyAgentDefinition, + input: unknown, + emit: (name: string, value: Record) => void, + signal: AbortSignal, +): InvokeAgentResult { + // Validate input against schema if provided + if (agent.inputSchema) { + const validated = agent.inputSchema['~standard'].validate(input) + if (validated instanceof Promise) { + throw new Error( + `Async input schema validation not supported in v1 (agent "${agent.name}")`, + ) + } + if (validated.issues) { + const err = new SchemaValidationError( + `Input schema validation failed for agent "${agent.name}"`, + validated.issues, + ) + throw err + } + input = validated.value + } + + const result = agent.run({ input, emit, signal } as any) as AgentRunResult + + // Shape (c): { stream, output } + if ( + typeof result === 'object' && + 'stream' in result && + 'output' in result + ) { + return { + stream: filterInnerRunBoundaries(result.stream), + output: result.output.then((o) => parseOutput(agent, o)), + } + } + + // Shape (a): AsyncIterable + if ( + typeof result === 'object' && + Symbol.asyncIterator in (result as any) + ) { + const stream = result as AsyncIterable + let resolveOutput!: (val: T) => void + let rejectOutput!: (err: unknown) => void + const output = new Promise((res, rej) => { + resolveOutput = res + rejectOutput = rej + }) + + async function* drain(): AsyncIterable { + let lastTextContent = '' + try { + for await (const chunk of filterInnerRunBoundaries(stream)) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT' && 'delta' in chunk) { + lastTextContent += String((chunk as any).delta ?? '') + } + yield chunk + } + // Try to parse final text as the typed output via the agent's outputSchema. + try { + const parsed = parseOutputFromText(agent, lastTextContent) + resolveOutput(parsed) + } catch (err) { + rejectOutput(err) + } + } catch (err) { + rejectOutput(err) + throw err + } + } + + return { stream: drain(), output } + } + + // Shape (b): Promise (or already-resolved value) + return { + stream: emptyStream(), + output: Promise.resolve(result as T).then((o) => parseOutput(agent, o)), + } +} + +async function* emptyStream(): AsyncIterable { + // intentionally empty +} + +/** + * Filter out RunStartedEvent and RunFinishedEvent emitted by an inner chat() + * call so the outer workflow run owns the run boundaries. + */ +async function* filterInnerRunBoundaries( + source: AsyncIterable, +): AsyncIterable { + for await (const chunk of source) { + if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') { + continue + } + yield chunk + } +} + +/** + * Validate raw output against the agent's outputSchema. + */ +function parseOutput(agent: AnyAgentDefinition, raw: unknown): T { + if (!agent.outputSchema) return raw as T + const validated = agent.outputSchema['~standard'].validate(raw) + if (validated instanceof Promise) { + throw new Error( + `Async output schema validation not supported in v1 (agent "${agent.name}")`, + ) + } + if (validated.issues) { + const err = new SchemaValidationError( + `Output schema validation failed for agent "${agent.name}"`, + validated.issues, + ) + throw err + } + return validated.value as T +} + +/** + * Parse JSON-shaped agent output from accumulated text. Used when the agent + * returned a raw stream (no explicit output Promise). For non-JSON outputs + * (string-typed), the raw text is returned. + */ +function parseOutputFromText(agent: AnyAgentDefinition, text: string): T { + if (!agent.outputSchema) return text as T + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + // Fall back to raw text — schema validation will fail with a clear message. + raw = text + } + return parseOutput(agent, raw) +} + +export class SchemaValidationError extends Error { + issues: ReadonlyArray + constructor(message: string, issues: ReadonlyArray) { + super(message) + this.name = 'SchemaValidationError' + this.issues = issues + } +} From f2fcaaa9d6a46b8d8188b7700800551a6d9dad58 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:58:13 +0200 Subject: [PATCH 08/50] feat(ai-orchestration): workflow engine drive loop --- .../src/engine/run-workflow.ts | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/engine/run-workflow.ts diff --git a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts new file mode 100644 index 000000000..c9fa1c5f0 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts @@ -0,0 +1,392 @@ +import { bindAgents } from '../primitives/bind-agents' +import { diffState, snapshotState } from './state-diff' +import { + approvalRequestedEvent, + runErrorEvent, + runFinishedEvent, + runStartedEvent, + stateDeltaEvent, + stateSnapshotEvent, + stepFinishedEvent, + stepStartedEvent, +} from './emit-events' +import { invokeAgent } from './invoke-agent' +import type { StreamChunk } from '@tanstack/ai' +import type { + AgentMap, + AnyWorkflowDefinition, + ApprovalResult, + LiveRun, + RunState, + StepDescriptor, + WorkflowRunArgs, +} from '../types' +import type { InMemoryRunStore } from '../run-store/in-memory' + +export interface RunWorkflowOptions { + workflow: AnyWorkflowDefinition + input: unknown + runStore: InMemoryRunStore + /** Optional: provide an existing runId (used for resume). If absent, generated. */ + runId?: string + /** Optional: external abort signal. */ + signal?: AbortSignal + /** Optional: thread ID for client-side correlation. */ + threadId?: string + /** + * Optional: called with the workflow's final output value before the store + * entry is deleted. Used by the parent engine to capture nested-workflow + * output across the store-delete boundary. + */ + outputSink?: (output: unknown) => void +} + +// ----- helpers ----- + +function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` +} + +function mergeStateDefaults( + workflow: AnyWorkflowDefinition, + initial: Record, +): Record { + if (workflow.stateSchema) { + const validated = workflow.stateSchema['~standard'].validate(initial) + if (!(validated instanceof Promise) && !validated.issues) { + return validated.value as Record + } + } + return initial +} + +function serializeError(err: unknown): { + name: string + message: string + stack?: string +} { + if (err instanceof Error) { + return { name: err.name, message: err.message, stack: err.stack } + } + return { name: 'UnknownError', message: String(err) } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * Run a workflow to completion or pause point. Returns an AsyncIterable of + * StreamChunk that the caller pipes to SSE. + * + * Pause semantics: when the user code yields an `approval` descriptor, the + * engine emits `approval-requested`, persists run state, stores the live + * generator handle in `runStore.setLive`, then ends the stream. Resume is a + * separate call to `resumeWorkflow`. + */ +export async function* runWorkflow( + options: RunWorkflowOptions, +): AsyncIterable { + const runId = options.runId ?? generateId('run') + const abortController = new AbortController() + if (options.signal) { + options.signal.addEventListener('abort', () => abortController.abort(), { + once: true, + }) + } + + const initialState = options.workflow.initialize + ? options.workflow.initialize({ input: options.input as any }) + : {} + const state = mergeStateDefaults( + options.workflow, + initialState as Record, + ) + + let runState: RunState = { + runId, + status: 'running', + workflowName: options.workflow.name, + input: options.input, + state, + createdAt: Date.now(), + updatedAt: Date.now(), + } + await options.runStore.set(runId, runState) + + yield runStartedEvent({ runId, threadId: options.threadId }) + yield stateSnapshotEvent({ snapshot: state }) + + const pendingEvents: Array = [] + const args: WorkflowRunArgs = { + input: options.input, + state, + agents: bindAgents(options.workflow.agents), + emit: (name, value) => { + pendingEvents.push({ + type: 'CUSTOM', + timestamp: Date.now(), + name, + value, + } as StreamChunk) + }, + signal: abortController.signal, + } + + const generator = options.workflow.run(args as any) + + const live: LiveRun = { + runState, + generator, + abortController, + approvalResolver: undefined, + } + options.runStore.setLive(runId, live) + + let prevState = snapshotState(state) + let nextValue: unknown = undefined + let finalOutput: unknown = undefined + + try { + for (;;) { + // Drain any custom events queued by emit() before advancing the generator. + while (pendingEvents.length > 0) yield pendingEvents.shift()! + + const result = await generator.next(nextValue as StepDescriptor) + + // Diff state that may have mutated during the user's generator step. + const delta = diffState(prevState, state) + if (delta.length > 0) { + yield stateDeltaEvent({ delta }) + prevState = snapshotState(state) + } + + if (result.done) { + finalOutput = result.value + break + } + + const descriptor: StepDescriptor = result.value + const stepId = generateId('step') + + // ---- agent ---- + if (descriptor.kind === 'agent') { + yield stepStartedEvent({ + stepId, + stepName: descriptor.name, + stepType: 'agent', + }) + + const { stream, output } = invokeAgent( + descriptor.agent, + descriptor.input, + args.emit, + abortController.signal, + ) + + for await (const chunk of stream) yield chunk + + let stepResult: unknown + try { + stepResult = await output + } catch (err) { + yield stepFinishedEvent({ + stepId, + stepName: descriptor.name, + content: { error: serializeError(err) }, + }) + nextValue = undefined + const thrown = await generator.throw(err) + if (thrown.done) { + finalOutput = thrown.value + break + } + continue + } + + yield stepFinishedEvent({ + stepId, + stepName: descriptor.name, + content: stepResult, + }) + nextValue = stepResult + continue + } + + // ---- nested-workflow ---- + if (descriptor.kind === 'nested-workflow') { + yield stepStartedEvent({ + stepId, + stepName: descriptor.name, + stepType: 'nested-workflow', + }) + + let nestedOutput: unknown = undefined + const nestedIter = runWorkflow({ + workflow: descriptor.workflow, + input: descriptor.input, + runStore: options.runStore, + signal: abortController.signal, + outputSink: (o) => { + nestedOutput = o + }, + }) + + for await (const chunk of nestedIter) { + // Filter inner run boundaries so the outer run owns them. + if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') { + continue + } + yield chunk + } + + yield stepFinishedEvent({ + stepId, + stepName: descriptor.name, + content: nestedOutput, + }) + nextValue = nestedOutput + continue + } + + // ---- approval (exhaustive last branch) ---- + { + const approvalDescriptor = descriptor + const approvalId = generateId('approval') + + yield stepStartedEvent({ + stepId, + stepName: 'approval', + stepType: 'approval', + }) + + yield approvalRequestedEvent({ + approvalId, + kind: 'workflow', + title: approvalDescriptor.title, + description: approvalDescriptor.description, + }) + + runState = { + ...runState, + status: 'paused', + state, + pendingApproval: { + approvalId, + title: approvalDescriptor.title, + description: approvalDescriptor.description, + }, + updatedAt: Date.now(), + } + live.runState = runState + await options.runStore.set(runId, runState) + + // SSE stream ends here; resumeWorkflow continues after client posts approval. + return + } + } + + // Notify the parent before we delete our store entry so the output is + // accessible across the store-delete boundary. + options.outputSink?.(finalOutput) + + runState = { + ...runState, + status: 'finished', + state, + output: finalOutput, + updatedAt: Date.now(), + } + await options.runStore.set(runId, runState) + yield runFinishedEvent({ runId, threadId: options.threadId }) + await options.runStore.delete(runId, 'finished') + } catch (err) { + if (abortController.signal.aborted) { + yield runErrorEvent({ + runId, + message: 'Workflow aborted', + code: 'aborted', + }) + await options.runStore.delete(runId, 'aborted') + return + } + yield runErrorEvent({ + runId, + message: errorMessage(err), + code: 'error', + }) + await options.runStore.delete(runId, 'error') + } +} + +/** + * Resume a paused workflow with an approval response. Returns the SSE stream + * for the resumed segment. + * + * v1 limitation: this only supports straight-line continuation after the + * approval. If the user code yields more agent/nested-workflow descriptors + * after the approval, those will fail. Refactor into a class with shared + * dispatch loop in v2. + */ +export async function* resumeWorkflow(args: { + runId: string + runStore: InMemoryRunStore + approval: ApprovalResult +}): AsyncIterable { + const live = args.runStore.getLive(args.runId) + if (!live) { + yield runErrorEvent({ + runId: args.runId, + message: `Run ${args.runId} not found (expired or never existed)`, + code: 'run_lost', + }) + return + } + + live.runState = { ...live.runState, status: 'running', updatedAt: Date.now() } + await args.runStore.set(args.runId, live.runState) + + yield runStartedEvent({ runId: args.runId }) + + const nextValue: unknown = args.approval + let prevState = snapshotState(live.runState.state) + let finalOutput: unknown = undefined + + try { + for (;;) { + const result = await live.generator.next(nextValue as StepDescriptor) + + const delta = diffState(prevState, live.runState.state) + if (delta.length > 0) { + yield stateDeltaEvent({ delta }) + prevState = snapshotState(live.runState.state) + } + + if (result.done) { + finalOutput = result.value + break + } + + throw new Error( + 'Resume after approval supports straight-line continuation in v1; ' + + 'extract dispatch loop into a class to handle nested yields after resume.', + ) + } + + live.runState = { + ...live.runState, + status: 'finished', + output: finalOutput, + updatedAt: Date.now(), + } + yield runFinishedEvent({ runId: args.runId }) + await args.runStore.delete(args.runId, 'finished') + } catch (err) { + yield runErrorEvent({ + runId: args.runId, + message: errorMessage(err), + code: 'error', + }) + await args.runStore.delete(args.runId, 'error') + } +} From 916055a9ed9fa6cdfb3eb1a0d5e329c2ff4f2125 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:03:20 +0200 Subject: [PATCH 09/50] fix(ai-orchestration): persist resumed state, share pendingEvents queue, plug RUN_ERROR runId - resumeWorkflow now calls runStore.set() before runStore.delete() so observers see the finished state - pendingEvents queue moved onto LiveRun so the emit() closure captured during runWorkflow is drained correctly by resumeWorkflow - Both drive loops drain live.pendingEvents at the top of each iteration - runErrorEvent now includes runId in the returned chunk --- .../src/engine/emit-events.ts | 1 + .../src/engine/run-workflow.ts | 24 ++++++++++++------- .../typescript/ai-orchestration/src/types.ts | 1 + 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/typescript/ai-orchestration/src/engine/emit-events.ts b/packages/typescript/ai-orchestration/src/engine/emit-events.ts index dedc394c7..6831fd3b6 100644 --- a/packages/typescript/ai-orchestration/src/engine/emit-events.ts +++ b/packages/typescript/ai-orchestration/src/engine/emit-events.ts @@ -38,6 +38,7 @@ export function runErrorEvent(args: { return { type: 'RUN_ERROR', timestamp: Date.now(), + runId: args.runId, message: args.message, code: args.code ?? 'error', } as StreamChunk diff --git a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts index c9fa1c5f0..aecf6d3a3 100644 --- a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts +++ b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts @@ -117,13 +117,20 @@ export async function* runWorkflow( yield runStartedEvent({ runId, threadId: options.threadId }) yield stateSnapshotEvent({ snapshot: state }) - const pendingEvents: Array = [] + const live: LiveRun = { + runState, + generator: undefined as unknown as LiveRun['generator'], + abortController, + approvalResolver: undefined, + pendingEvents: [], + } + const args: WorkflowRunArgs = { input: options.input, state, agents: bindAgents(options.workflow.agents), emit: (name, value) => { - pendingEvents.push({ + live.pendingEvents.push({ type: 'CUSTOM', timestamp: Date.now(), name, @@ -134,13 +141,8 @@ export async function* runWorkflow( } const generator = options.workflow.run(args as any) + live.generator = generator - const live: LiveRun = { - runState, - generator, - abortController, - approvalResolver: undefined, - } options.runStore.setLive(runId, live) let prevState = snapshotState(state) @@ -150,7 +152,7 @@ export async function* runWorkflow( try { for (;;) { // Drain any custom events queued by emit() before advancing the generator. - while (pendingEvents.length > 0) yield pendingEvents.shift()! + while (live.pendingEvents.length > 0) yield live.pendingEvents.shift()! const result = await generator.next(nextValue as StepDescriptor) @@ -354,6 +356,9 @@ export async function* resumeWorkflow(args: { try { for (;;) { + // Drain any custom events queued by emit() before advancing the generator. + while (live.pendingEvents.length > 0) yield live.pendingEvents.shift()! + const result = await live.generator.next(nextValue as StepDescriptor) const delta = diffState(prevState, live.runState.state) @@ -379,6 +384,7 @@ export async function* resumeWorkflow(args: { output: finalOutput, updatedAt: Date.now(), } + await args.runStore.set(args.runId, live.runState) yield runFinishedEvent({ runId: args.runId }) await args.runStore.delete(args.runId, 'finished') } catch (err) { diff --git a/packages/typescript/ai-orchestration/src/types.ts b/packages/typescript/ai-orchestration/src/types.ts index beb8d8049..d7cb6173a 100644 --- a/packages/typescript/ai-orchestration/src/types.ts +++ b/packages/typescript/ai-orchestration/src/types.ts @@ -188,4 +188,5 @@ export interface LiveRun { generator: AsyncGenerator abortController: AbortController approvalResolver?: (result: ApprovalResult) => void + pendingEvents: Array } From 2592f84aa99c822e453ada6bfb89204871aca124 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:08:14 +0200 Subject: [PATCH 10/50] feat(ai-orchestration): public API helpers, SSE response, and index exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Tasks 3.1–3.4 and 4.1: defineAgent, defineWorkflow, defineOrchestrator factory functions; toWorkflowSSEResponse SSE helper; and wires up the full public API surface in src/index.ts. --- .../src/define/define-agent.ts | 42 +++++++ .../src/define/define-orchestrator.ts | 114 ++++++++++++++++++ .../src/define/define-workflow.ts | 68 +++++++++++ .../typescript/ai-orchestration/src/index.ts | 56 ++++++++- .../ai-orchestration/src/server/index.ts | 1 + .../src/server/sse-response.ts | 13 ++ 6 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 packages/typescript/ai-orchestration/src/define/define-agent.ts create mode 100644 packages/typescript/ai-orchestration/src/define/define-orchestrator.ts create mode 100644 packages/typescript/ai-orchestration/src/define/define-workflow.ts create mode 100644 packages/typescript/ai-orchestration/src/server/index.ts create mode 100644 packages/typescript/ai-orchestration/src/server/sse-response.ts diff --git a/packages/typescript/ai-orchestration/src/define/define-agent.ts b/packages/typescript/ai-orchestration/src/define/define-agent.ts new file mode 100644 index 000000000..200505c6b --- /dev/null +++ b/packages/typescript/ai-orchestration/src/define/define-agent.ts @@ -0,0 +1,42 @@ +import type { + AgentDefinition, + AgentRunArgs, + AgentRunResult, + InferSchema, + SchemaInput, +} from '../types' + +export interface DefineAgentConfig< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TName extends string, +> { + name: TName + description?: string + input?: TInputSchema + output?: TOutputSchema + run: ( + args: AgentRunArgs< + TInputSchema extends SchemaInput ? InferSchema : unknown + >, + ) => AgentRunResult< + TOutputSchema extends SchemaInput ? InferSchema : unknown + > +} + +export function defineAgent< + TInputSchema extends SchemaInput | undefined = undefined, + TOutputSchema extends SchemaInput | undefined = undefined, + TName extends string = string, +>( + config: DefineAgentConfig, +): AgentDefinition { + return { + __kind: 'agent', + name: config.name, + description: config.description, + inputSchema: config.input, + outputSchema: config.output, + run: config.run, + } +} diff --git a/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts b/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts new file mode 100644 index 000000000..6aab3de6f --- /dev/null +++ b/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts @@ -0,0 +1,114 @@ +import { defineWorkflow } from './define-workflow' +import type { + AgentMap, + InferSchema, + SchemaInput, + StepGenerator, + WorkflowDefinition, +} from '../types' + +export type RouterDecision< + TAgents extends AgentMap, + TOutputSchema extends SchemaInput | undefined, +> = + | { + done: true + output: TOutputSchema extends SchemaInput + ? InferSchema + : unknown + } + | { + done?: false + agent: keyof TAgents + input: unknown + } + +export interface DefineOrchestratorConfig< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TStateSchema extends SchemaInput | undefined, + TAgents extends AgentMap, +> { + name: string + description?: string + input?: TInputSchema + output?: TOutputSchema + state?: TStateSchema + agents: TAgents + initialize?: (args: { + input: TInputSchema extends SchemaInput + ? InferSchema + : unknown + }) => TStateSchema extends SchemaInput + ? Partial> + : Record + /** Max routing turns before forcing termination. Default 12. */ + maxTurns?: number + /** + * Routing decision generator. Returns `{ done: true, output }` to finish, + * or `{ agent: 'name', input: {...} }` to dispatch to a declared agent. + */ + router: (args: { + input: TInputSchema extends SchemaInput + ? InferSchema + : unknown + state: TStateSchema extends SchemaInput + ? InferSchema + : Record + turn: number + }) => StepGenerator> +} + +export function defineOrchestrator< + TInputSchema extends SchemaInput | undefined = undefined, + TOutputSchema extends SchemaInput | undefined = undefined, + TStateSchema extends SchemaInput | undefined = undefined, + TAgents extends AgentMap = AgentMap, +>( + config: DefineOrchestratorConfig< + TInputSchema, + TOutputSchema, + TStateSchema, + TAgents + >, +): WorkflowDefinition { + const maxTurns = config.maxTurns ?? 12 + + return defineWorkflow({ + name: config.name, + description: config.description, + input: config.input, + output: config.output, + state: config.state, + agents: config.agents, + initialize: config.initialize, + // eslint-disable-next-line @typescript-eslint/require-await + run: async function* (args) { + for (let turn = 0; turn < maxTurns; turn++) { + const decision = yield* config.router({ + input: args.input as any, + state: args.state as any, + turn, + }) + + if (decision.done) { + return decision.output as any + } + + const agentName = decision.agent as string + const boundAgent = (args.agents as any)[agentName] + if (typeof boundAgent !== 'function') { + throw new Error( + `Orchestrator "${config.name}": router returned unknown agent "${agentName}"`, + ) + } + // Discard agent return value; orchestrator routes again next turn. + yield* boundAgent(decision.input) + } + + throw new Error( + `Orchestrator "${config.name}": exceeded maxTurns (${maxTurns})`, + ) + }, + }) +} diff --git a/packages/typescript/ai-orchestration/src/define/define-workflow.ts b/packages/typescript/ai-orchestration/src/define/define-workflow.ts new file mode 100644 index 000000000..ef2cc6487 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/define/define-workflow.ts @@ -0,0 +1,68 @@ +import type { + AgentMap, + InferSchema, + SchemaInput, + StepDescriptor, + WorkflowDefinition, + WorkflowRunArgs, +} from '../types' + +export interface DefineWorkflowConfig< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TStateSchema extends SchemaInput | undefined, + TAgents extends AgentMap, +> { + name: string + description?: string + input?: TInputSchema + output?: TOutputSchema + state?: TStateSchema + agents: TAgents + initialize?: (args: { + input: TInputSchema extends SchemaInput + ? InferSchema + : unknown + }) => TStateSchema extends SchemaInput + ? Partial> + : Record + run: ( + args: WorkflowRunArgs< + TInputSchema extends SchemaInput ? InferSchema : unknown, + TStateSchema extends SchemaInput + ? InferSchema + : Record, + TAgents + >, + ) => AsyncGenerator< + StepDescriptor, + TOutputSchema extends SchemaInput ? InferSchema : unknown, + unknown + > +} + +export function defineWorkflow< + TInputSchema extends SchemaInput | undefined = undefined, + TOutputSchema extends SchemaInput | undefined = undefined, + TStateSchema extends SchemaInput | undefined = undefined, + TAgents extends AgentMap = AgentMap, +>( + config: DefineWorkflowConfig< + TInputSchema, + TOutputSchema, + TStateSchema, + TAgents + >, +): WorkflowDefinition { + return { + __kind: 'workflow', + name: config.name, + description: config.description, + inputSchema: config.input, + outputSchema: config.output, + stateSchema: config.state, + agents: config.agents, + initialize: config.initialize, + run: config.run, + } +} diff --git a/packages/typescript/ai-orchestration/src/index.ts b/packages/typescript/ai-orchestration/src/index.ts index fff9e46a2..82bcd1358 100644 --- a/packages/typescript/ai-orchestration/src/index.ts +++ b/packages/typescript/ai-orchestration/src/index.ts @@ -1,2 +1,54 @@ -// Public API surface — populated by later tasks -export {} +// ===== Definitions ===== +export { defineAgent } from './define/define-agent' +export type { DefineAgentConfig } from './define/define-agent' +export { defineWorkflow } from './define/define-workflow' +export type { DefineWorkflowConfig } from './define/define-workflow' +export { defineOrchestrator } from './define/define-orchestrator' +export type { + DefineOrchestratorConfig, + RouterDecision, +} from './define/define-orchestrator' + +// ===== Generator primitives ===== +export { approve } from './primitives/approve' +export type { ApproveOptions } from './primitives/approve' +export { retry } from './primitives/retry' +export type { RetryOptions } from './primitives/retry' + +// ===== Server-side run ===== +export { runWorkflow, resumeWorkflow } from './engine/run-workflow' +export type { RunWorkflowOptions } from './engine/run-workflow' +export { toWorkflowSSEResponse } from './server' + +// ===== Run store ===== +export { inMemoryRunStore } from './run-store/in-memory' +export type { + InMemoryRunStore, + InMemoryRunStoreOptions, +} from './run-store/in-memory' +export type { + DeleteReason, + RunState, + RunStatus, + RunStore, +} from './types' + +// ===== Errors ===== +export { SchemaValidationError } from './engine/invoke-agent' + +// ===== Public types ===== +export type { + AgentDefinition, + AgentMap, + AgentRunArgs, + AgentRunResult, + ApprovalResult, + BoundAgents, + EmitFn, + InferSchema, + SchemaInput, + StepDescriptor, + StepGenerator, + WorkflowDefinition, + WorkflowRunArgs, +} from './types' diff --git a/packages/typescript/ai-orchestration/src/server/index.ts b/packages/typescript/ai-orchestration/src/server/index.ts new file mode 100644 index 000000000..b6e6d7177 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/server/index.ts @@ -0,0 +1 @@ +export { toWorkflowSSEResponse } from './sse-response' diff --git a/packages/typescript/ai-orchestration/src/server/sse-response.ts b/packages/typescript/ai-orchestration/src/server/sse-response.ts new file mode 100644 index 000000000..4a16bf811 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/server/sse-response.ts @@ -0,0 +1,13 @@ +import { toServerSentEventsResponse } from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Convert a workflow stream into an SSE Response. Re-export of + * `toServerSentEventsResponse` for convenience and discoverability inside + * orchestration consumers. + */ +export function toWorkflowSSEResponse( + stream: AsyncIterable, +): Response { + return toServerSentEventsResponse(stream) +} From 8b3423a89789bdbc3acdda05bf290b6f48d5b86a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:12:03 +0200 Subject: [PATCH 11/50] feat(ai-client): WorkflowClient --- packages/typescript/ai-client/src/index.ts | 10 + .../ai-client/src/workflow-client.ts | 320 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 packages/typescript/ai-client/src/workflow-client.ts diff --git a/packages/typescript/ai-client/src/index.ts b/packages/typescript/ai-client/src/index.ts index 0f86ae891..f09ae7f8b 100644 --- a/packages/typescript/ai-client/src/index.ts +++ b/packages/typescript/ai-client/src/index.ts @@ -63,6 +63,16 @@ export { type SubscribeConnectionAdapter, } from './connection-adapters' +export { WorkflowClient } from './workflow-client' +export type { + WorkflowApproval, + WorkflowClientOptions, + WorkflowClientState, + WorkflowError, + WorkflowStatus, + WorkflowStep, +} from './workflow-client' + // Re-export message converters from @tanstack/ai export { uiMessageToModelMessages, diff --git a/packages/typescript/ai-client/src/workflow-client.ts b/packages/typescript/ai-client/src/workflow-client.ts new file mode 100644 index 000000000..4c8e3cebe --- /dev/null +++ b/packages/typescript/ai-client/src/workflow-client.ts @@ -0,0 +1,320 @@ +import type { + ConnectConnectionAdapter, + SubscribeConnectionAdapter, +} from './connection-adapters' + +export interface WorkflowApproval { + approvalId: string + description?: string + title: string +} + +export interface WorkflowError { + code?: string + message: string +} + +export type WorkflowStatus = + | 'aborted' + | 'error' + | 'finished' + | 'idle' + | 'paused' + | 'running' + +export interface WorkflowStep { + finishedAt?: number + /** Result from STEP_FINISHED.content */ + result?: unknown + startedAt: number + stepId: string + stepName: string + stepType?: 'agent' | 'approval' | 'nested-workflow' + status: 'failed' | 'finished' | 'running' +} + +export interface WorkflowClientState { + /** Live text accumulating in the active agent step, for streaming UI. */ + currentStep: WorkflowStep | null + currentText: string + error: WorkflowError | null + output: TOutput | null + pendingApproval: WorkflowApproval | null + runId: string | null + state: TState | null + status: WorkflowStatus + steps: Array +} + +export interface WorkflowClientOptions { + /** Optional: arbitrary extra body fields to send with the start request. */ + body?: Record + connection: ConnectConnectionAdapter | SubscribeConnectionAdapter + onCustomEvent?: (name: string, value: Record) => void + onStateChange?: (state: WorkflowClientState) => void +} + +const initialState: WorkflowClientState = { + currentStep: null, + currentText: '', + error: null, + output: null, + pendingApproval: null, + runId: null, + state: null, + status: 'idle', + steps: [], +} + +/** + * Headless workflow run client. Composes the same connection adapters as + * ChatClient. Subscribers see a reducer-driven state that mirrors the + * server-side workflow run. + */ +export class WorkflowClient< + TInput = unknown, + TOutput = unknown, + TState = unknown, +> { + private clientState: WorkflowClientState = { + ...initialState, + } as WorkflowClientState + private opts: WorkflowClientOptions + private subscribers = new Set< + (s: WorkflowClientState) => void + >() + + constructor(opts: WorkflowClientOptions) { + this.opts = opts + } + + get state(): WorkflowClientState { + return this.clientState + } + + subscribe( + cb: (s: WorkflowClientState) => void, + ): () => void { + this.subscribers.add(cb) + return () => { + this.subscribers.delete(cb) + } + } + + async approve(approved: boolean): Promise { + if (!this.clientState.pendingApproval || !this.clientState.runId) { + throw new Error('No pending approval') + } + const approvalId = this.clientState.pendingApproval.approvalId + const runId = this.clientState.runId + this.setState({ + pendingApproval: null, + status: 'running', + }) + const workflowStream = this.openStream({ + approval: { approvalId, approved }, + runId, + }) + await this.consumeStream(workflowStream) + } + + async start(input: TInput): Promise { + this.setState({ + ...(initialState as WorkflowClientState), + status: 'running', + }) + const workflowStream = this.openStream({ input }) + await this.consumeStream(workflowStream) + } + + stop(): void { + if (!this.clientState.runId) return + this.openStream({ + abort: true, + runId: this.clientState.runId, + }) + this.setState({ status: 'aborted' }) + } + + // ---------- internal ---------- + + private async consumeStream(stream: AsyncIterable): Promise { + for await (const raw of stream) { + this.handleChunk(raw as Record) + } + } + + private handleChunk(chunk: Record): void { + const type = chunk.type as string + switch (type) { + case 'CUSTOM': { + const name = chunk.name as string + const value = chunk.value as Record + if ( + name === 'approval-requested' && + (value as { kind?: string }).kind === 'workflow' + ) { + this.setState({ + pendingApproval: { + approvalId: value.approvalId as string, + description: value.description as string | undefined, + title: value.title as string, + }, + status: 'paused', + }) + } else { + this.opts.onCustomEvent?.(name, value) + } + break + } + case 'RUN_ERROR': { + const code = chunk.code as string | undefined + this.setState({ + error: { + code, + message: chunk.message as string, + }, + status: code === 'aborted' ? 'aborted' : 'error', + }) + break + } + case 'RUN_FINISHED': + this.setState({ status: 'finished' }) + break + case 'RUN_STARTED': + this.setState({ + runId: chunk.runId as string, + status: 'running', + } as Partial>) + break + case 'STATE_DELTA': { + const next = applyJsonPatch( + this.clientState.state, + chunk.delta as Array>, + ) + this.setState({ state: next as TState }) + break + } + case 'STATE_SNAPSHOT': + this.setState({ state: chunk.snapshot as TState }) + break + case 'STEP_FINISHED': { + const stepId = chunk.stepId as string + const content: unknown = chunk.content + const isFailed = + content !== null && + typeof content === 'object' && + 'error' in (content as Record) + const updated = this.clientState.steps.map((s) => + s.stepId === stepId + ? { + ...s, + finishedAt: chunk.timestamp as number, + result: content, + status: isFailed ? ('failed' as const) : ('finished' as const), + } + : s, + ) + this.setState({ currentStep: null, currentText: '', steps: updated }) + break + } + case 'STEP_STARTED': { + const step: WorkflowStep = { + startedAt: chunk.timestamp as number, + status: 'running', + stepId: chunk.stepId as string, + stepName: chunk.stepName as string, + stepType: chunk.stepType as WorkflowStep['stepType'], + } + this.setState({ + currentStep: step, + currentText: '', + steps: [...this.clientState.steps, step], + }) + break + } + case 'TEXT_MESSAGE_CONTENT': + this.setState({ + currentText: + this.clientState.currentText + (chunk.delta as string), + }) + break + } + } + + private openStream(body: Record): AsyncIterable { + const conn = this.opts.connection + const fullBody = { ...this.opts.body, ...body } + if ('connect' in conn) { + return conn.connect( + fullBody as unknown as Parameters[0], + ) as AsyncIterable + } + throw new Error( + 'Subscribe-mode connection adapters not supported for workflow client in v1', + ) + } + + private setState( + patch: Partial>, + ): void { + this.clientState = { ...this.clientState, ...patch } + for (const sub of this.subscribers) sub(this.clientState) + this.opts.onStateChange?.(this.clientState as WorkflowClientState) + } +} + +// Minimal RFC 6902 patch applier — keeps the client zero-dep on json-patch libs. +// Handles replace/add/remove on nested paths and array indices. +function applyJsonPatch( + base: unknown, + ops: Array>, +): unknown { + const doc = + base === null || base === undefined + ? {} + : (structuredClone(base) as Record) + for (const op of ops) { + const segments = String(op.path) + .split('/') + .slice(1) + .map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~')) + if (op.op === 'replace' || op.op === 'add') { + setAt(doc, segments, op.value) + } else if (op.op === 'remove') { + removeAt(doc, segments) + } + } + return doc +} + +function removeAt( + target: Record, + segments: Array, +): void { + if (segments.length === 0) return + const last = segments[segments.length - 1]! + let cursor: Record = target + for (let i = 0; i < segments.length - 1; i++) { + cursor = cursor[segments[i]!] as Record + } + if (Array.isArray(cursor)) + (cursor as unknown as Array).splice(Number(last), 1) + else delete cursor[last] +} + +function setAt( + target: Record, + segments: Array, + value: unknown, +): void { + if (segments.length === 0) return + const last = segments[segments.length - 1]! + let cursor: Record = target + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]! + if (cursor[seg] === undefined) cursor[seg] = {} + cursor = cursor[seg] as Record + } + cursor[last] = value +} From 018763d82ed48c9d9cfaccae5ba5e1454dd8920b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:14:56 +0200 Subject: [PATCH 12/50] feat(ai-react): useWorkflow + useOrchestration --- packages/typescript/ai-react/src/index.ts | 7 +++ .../typescript/ai-react/src/use-workflow.ts | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/typescript/ai-react/src/use-workflow.ts diff --git a/packages/typescript/ai-react/src/index.ts b/packages/typescript/ai-react/src/index.ts index 5ce8c9911..baf8a1b81 100644 --- a/packages/typescript/ai-react/src/index.ts +++ b/packages/typescript/ai-react/src/index.ts @@ -51,6 +51,13 @@ export type { UseGenerateVideoReturn, } from './use-generate-video' +// Workflow/Orchestration hooks +export { useOrchestration, useWorkflow } from './use-workflow' +export type { + UseWorkflowOptions, + UseWorkflowReturn, +} from './use-workflow' + // Re-export from ai-client for convenience export { fetchServerSentEvents, diff --git a/packages/typescript/ai-react/src/use-workflow.ts b/packages/typescript/ai-react/src/use-workflow.ts new file mode 100644 index 000000000..132a810eb --- /dev/null +++ b/packages/typescript/ai-react/src/use-workflow.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { WorkflowClient } from '@tanstack/ai-client' +import type { + WorkflowClientOptions, + WorkflowClientState, +} from '@tanstack/ai-client' + +export interface UseWorkflowOptions extends WorkflowClientOptions {} + +export interface UseWorkflowReturn< + TInput = unknown, + TOutput = unknown, + TState = unknown, +> extends WorkflowClientState { + approve: (approved: boolean) => Promise + start: (input: TInput) => Promise + stop: () => void +} + +export function useWorkflow< + TInput = unknown, + TOutput = unknown, + TState = unknown, +>(opts: UseWorkflowOptions): UseWorkflowReturn { + const optsRef = useRef(opts) + optsRef.current = opts + + const client = useMemo( + () => + new WorkflowClient({ + body: optsRef.current.body, + connection: optsRef.current.connection, + onCustomEvent: (name, value) => + optsRef.current.onCustomEvent?.(name, value), + }), + [opts.connection], + ) + + const [state, setState] = useState(client.state) + + useEffect(() => { + return client.subscribe(setState) + }, [client]) + + const approve = useCallback( + (approved: boolean) => client.approve(approved), + [client], + ) + const start = useCallback((input: TInput) => client.start(input), [client]) + const stop = useCallback(() => { + client.stop() + }, [client]) + + return { ...state, approve, start, stop } +} + +/** Alias — same hook, different vocabulary. Orchestrators are workflows. */ +export const useOrchestration = useWorkflow From c1334db0e0a8147e5e4d2516ae9ecd81344ac733 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:19:20 +0200 Subject: [PATCH 13/50] feat(ts-react-chat): article workflow demo --- examples/ts-react-chat/package.json | 1 + .../src/lib/workflows/article-workflow.ts | 158 ++++++++++++++++++ pnpm-lock.yaml | 3 + 3 files changed, 162 insertions(+) create mode 100644 examples/ts-react-chat/src/lib/workflows/article-workflow.ts diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 60cfe6836..45435b76c 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -21,6 +21,7 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-orchestration": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.154.7", diff --git a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts new file mode 100644 index 000000000..6e9cead12 --- /dev/null +++ b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts @@ -0,0 +1,158 @@ +import { z } from 'zod' +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + approve, + defineAgent, + defineWorkflow, +} from '@tanstack/ai-orchestration' + +// ===== Schemas ===== +const Draft = z.object({ + title: z.string(), + paragraphs: z.array(z.string()), +}) + +const Review = z.object({ + verdict: z.enum(['pass', 'block']), + findings: z.array(z.string()), +}) + +const ArticleInput = z.object({ topic: z.string() }) + +const ArticleOutput = z.union([ + z.object({ + ok: z.literal(true), + article: Draft, + }), + z.object({ + ok: z.literal(false), + reason: z.string(), + }), +]) + +const ArticleState = z.object({ + phase: z + .enum(['drafting', 'reviewing', 'awaiting-approval', 'editing', 'done']) + .default('drafting'), + draft: Draft.optional(), + legalReview: Review.optional(), + skepticReview: Review.optional(), +}) + +// ===== Agents ===== +const writer = defineAgent({ + name: 'writer', + input: z.object({ topic: z.string() }), + output: Draft, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: Draft, + systemPrompts: [ + 'You are a non-fiction writer. Produce a factual three-paragraph article on the topic. Reply only with valid JSON matching the schema.', + ], + messages: [{ role: 'user', content: input.topic }], + }), +}) + +function reviewerFor(role: 'legal' | 'skeptic') { + return defineAgent({ + name: `${role}Reviewer`, + input: z.object({ draft: Draft }), + output: Review, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: Review, + systemPrompts: [ + role === 'legal' + ? 'You are a legal reviewer. Flag any compliance issues. Verdict "block" if issues, otherwise "pass".' + : 'You are a skeptic. Flag unsupported claims. Verdict "block" if claims are unsupported.', + ], + messages: [ + { + role: 'user', + content: `Title: ${input.draft.title}\n\n${input.draft.paragraphs.join('\n\n')}`, + }, + ], + }), + }) +} + +const editor = defineAgent({ + name: 'editor', + input: z.object({ + draft: Draft, + notes: z.array(z.string()), + }), + output: Draft, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: Draft, + systemPrompts: [ + 'You are an editor. Polish the draft, addressing the reviewer notes. Reply with the polished JSON.', + ], + messages: [ + { + role: 'user', + content: `Draft: ${JSON.stringify(input.draft)}\n\nNotes: ${input.notes.join('; ')}`, + }, + ], + }), +}) + +// ===== Workflow ===== +export const articleWorkflow = defineWorkflow({ + name: 'article-workflow', + input: ArticleInput, + output: ArticleOutput, + state: ArticleState, + agents: { + writer, + legal: reviewerFor('legal'), + skeptic: reviewerFor('skeptic'), + editor, + }, + initialize: () => ({ phase: 'drafting' as const }), + run: async function* ({ input, state, agents }) { + state.phase = 'drafting' + const draft = yield* agents.writer({ topic: input.topic }) + state.draft = draft + + state.phase = 'reviewing' + const legal = yield* agents.legal({ draft }) + state.legalReview = legal + if (legal.verdict === 'block') { + state.phase = 'done' + return { ok: false as const, reason: `legal: ${legal.findings.join('; ')}` } + } + + const skeptic = yield* agents.skeptic({ draft }) + state.skepticReview = skeptic + if (skeptic.verdict === 'block') { + state.phase = 'done' + return { ok: false as const, reason: `skeptic: ${skeptic.findings.join('; ')}` } + } + + state.phase = 'awaiting-approval' + const decision = yield* approve({ + title: 'Publish this draft?', + description: `"${draft.title}" passed both reviews.`, + }) + if (!decision.approved) { + state.phase = 'done' + return { ok: false as const, reason: 'user denied' } + } + + state.phase = 'editing' + const final = yield* agents.editor({ + draft, + notes: [...legal.findings, ...skeptic.findings], + }) + state.draft = final + state.phase = 'done' + return { ok: true as const, article: final } + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 428fecbff..e9caa0951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: '@tanstack/ai-openrouter': specifier: workspace:* version: link:../../packages/typescript/ai-openrouter + '@tanstack/ai-orchestration': + specifier: workspace:* + version: link:../../packages/typescript/ai-orchestration '@tanstack/ai-react': specifier: workspace:* version: link:../../packages/typescript/ai-react From f8ce39d26dc7f1f049a8259245b02451bc8a6f99 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:25:59 +0200 Subject: [PATCH 14/50] feat(ts-react-chat): feature orchestrator demo --- .../src/lib/workflows/orchestrator.ts | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 examples/ts-react-chat/src/lib/workflows/orchestrator.ts diff --git a/examples/ts-react-chat/src/lib/workflows/orchestrator.ts b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts new file mode 100644 index 000000000..dc78139cc --- /dev/null +++ b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts @@ -0,0 +1,334 @@ +import { z } from 'zod' +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + defineAgent, + defineOrchestrator, + defineWorkflow, + type AgentMap, + type RouterDecision, + type StepGenerator, +} from '@tanstack/ai-orchestration' + +// ===== Schemas ===== +const FeatureSpec = z.object({ + title: z.string(), + summary: z.string(), + files: z.array(z.string()), +}) + +const FilePatch = z.object({ + filename: z.string(), + patch: z.string(), +}) + +const ImplementResult = z.object({ + patches: z.array(FilePatch), + rationale: z.string(), +}) + +const OrchestratorState = z.object({ + phase: z + .enum(['scoping', 'awaiting-approval', 'implementing', 'review', 'done']) + .default('scoping'), + spec: FeatureSpec.optional(), + result: ImplementResult.optional(), + lastUserMessage: z.string().default(''), +}) + +const OrchestratorInput = z.object({ userMessage: z.string() }) +const OrchestratorOutput = z.object({ + phase: z.enum(['scoping', 'implementing', 'review', 'done']), + result: ImplementResult.optional(), +}) + +// ===== Agents ===== +const specAgent = defineAgent({ + name: 'spec', + input: z.object({ + userMessage: z.string(), + existingSpec: FeatureSpec.optional(), + }), + output: z.object({ + spec: FeatureSpec, + ready: z.boolean(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + spec: FeatureSpec, + ready: z.boolean(), + }), + systemPrompts: [ + 'Given a feature request, refine it into a concrete spec with title, summary, and files to change. Mark ready=true when the spec is implementation-ready.', + ], + messages: [ + { + role: 'user', + content: + `Feature request: ${input.userMessage}\n\n` + + (input.existingSpec + ? `Existing draft: ${JSON.stringify(input.existingSpec)}` + : ''), + }, + ], + }), +}) + +const plannerAgent = defineAgent({ + name: 'planner', + input: z.object({ spec: FeatureSpec }), + output: z.object({ + files: z.array(z.string()), + rationale: z.string(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + files: z.array(z.string()), + rationale: z.string(), + }), + systemPrompts: [ + 'Given a spec, list the exact files that need patching and a one-paragraph rationale.', + ], + messages: [{ role: 'user', content: JSON.stringify(input.spec) }], + }), +}) + +const coderAgent = defineAgent({ + name: 'coder', + input: z.object({ filename: z.string(), spec: FeatureSpec }), + output: FilePatch, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: FilePatch, + systemPrompts: [ + 'Generate a unified-diff-style patch for the given file based on the spec. Use a markdown code block in the `patch` field.', + ], + messages: [ + { + role: 'user', + content: `File: ${input.filename}\nSpec: ${JSON.stringify(input.spec)}`, + }, + ], + }), +}) + +// ===== implement: sub-workflow used as an "agent" by the orchestrator ===== +export const implementWorkflow = defineWorkflow({ + name: 'implement', + input: z.object({ spec: FeatureSpec }), + output: ImplementResult, + state: z.object({}).default({}), + agents: { planner: plannerAgent, coder: coderAgent }, + run: async function* ({ input, agents }) { + const plan = yield* agents.planner({ spec: input.spec }) + const patches = [] + for (const filename of plan.files) { + const patch = yield* agents.coder({ filename, spec: input.spec }) + patches.push(patch) + } + return { patches, rationale: plan.rationale } + }, +}) + +const reviewAgent = defineAgent({ + name: 'review', + input: z.object({ result: ImplementResult, userMessage: z.string() }), + output: z.object({ + verdict: z.enum(['accept', 'refine', 'reject']), + notes: z.string(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + verdict: z.enum(['accept', 'refine', 'reject']), + notes: z.string(), + }), + systemPrompts: [ + "Read the user's feedback on the implementation. Decide accept | refine | reject.", + ], + messages: [ + { + role: 'user', + content: `Implementation:\n${JSON.stringify(input.result)}\n\nUser feedback: ${input.userMessage}`, + }, + ], + }), +}) + +const triageAgent = defineAgent({ + name: 'triage', + input: z.object({ + userMessage: z.string(), + phase: z.string(), + hasSpec: z.boolean(), + hasResult: z.boolean(), + }), + output: z.object({ + next: z.enum(['spec', 'await-approval', 'implement', 'review', 'done']), + reason: z.string(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + next: z.enum([ + 'spec', + 'await-approval', + 'implement', + 'review', + 'done', + ]), + reason: z.string(), + }), + systemPrompts: [ + 'Decide the next phase given current state. Phases: spec (refine the spec), await-approval (request user OK to implement), implement (run code generation), review (read user feedback), done (finish).', + ], + messages: [{ role: 'user', content: JSON.stringify(input) }], + }), +}) + +// ===== Orchestrator ===== + +// v1 ergonomics gap: the router runs outside the BoundAgents context so +// specific-agent types can't reach here. RouterDecision is wider +// than RouterDecision due to the contravariant `agent` key. +// Extracting the router and casting it `as any` is the v1 workaround; fix in +// v2 by threading TAgents all the way into the router signature. +function* featureRouter({ + input, + state, +}: { + input: { userMessage: string } + state: { + phase: string + spec?: { title: string; summary: string; files: Array } + result?: { + patches: Array<{ filename: string; patch: string }> + rationale: string + } + lastUserMessage: string + } +}): StepGenerator> { + // Inline triage call. The orchestrator's router runs outside the bound + // agents context (v1 ergonomics gap), so we yield raw step descriptors. + const triageDescriptor = { + kind: 'agent' as const, + name: 'triage', + input: { + userMessage: state.lastUserMessage || input.userMessage, + phase: state.phase, + hasSpec: !!state.spec, + hasResult: !!state.result, + }, + agent: triageAgent, + } + const triageResult = (yield triageDescriptor) as unknown as { + next: 'spec' | 'await-approval' | 'implement' | 'review' | 'done' + reason: string + } + + if (triageResult.next === 'done') { + return { + done: true as const, + output: { + phase: state.phase as 'scoping' | 'implementing' | 'review' | 'done', + result: state.result, + }, + } + } + + if (triageResult.next === 'spec') { + state.phase = 'scoping' + return { + agent: 'spec' as const, + input: { userMessage: state.lastUserMessage }, + } + } + + if (triageResult.next === 'await-approval') { + // yield* approve() causes a TNext mismatch inside the router generator + // (v1 ergonomics gap: approve's TNext=ApprovalResult conflicts with + // the router's TNext=RouterDecision). Yield the descriptor directly. + const approvalDescriptor = { + kind: 'approval' as const, + title: 'Start implementation?', + description: state.spec + ? `Spec ready: "${state.spec.title}". Begin implementing?` + : 'Begin implementing?', + } + const approval = (yield approvalDescriptor) as unknown as { + approved: boolean + approvalId: string + } + if (approval.approved) { + state.phase = 'implementing' + if (!state.spec) { + throw new Error('No spec to implement') + } + return { + agent: 'implement' as const, + input: { spec: state.spec }, + } + } + state.phase = 'scoping' + return { + agent: 'spec' as const, + input: { userMessage: state.lastUserMessage }, + } + } + + if (triageResult.next === 'implement') { + state.phase = 'implementing' + if (!state.spec) { + throw new Error('No spec to implement') + } + return { + agent: 'implement' as const, + input: { spec: state.spec }, + } + } + + if (triageResult.next === 'review') { + state.phase = 'review' + if (!state.result) { + throw new Error('No result to review') + } + return { + agent: 'review' as const, + input: { result: state.result, userMessage: state.lastUserMessage }, + } + } + + return { + done: true as const, + output: { + phase: state.phase as 'scoping' | 'implementing' | 'review' | 'done', + result: state.result, + }, + } +} + +export const featureOrchestrator = defineOrchestrator({ + name: 'feature-orchestrator', + input: OrchestratorInput, + output: OrchestratorOutput, + state: OrchestratorState, + agents: { + implement: implementWorkflow, + review: reviewAgent, + spec: specAgent, + triage: triageAgent, + }, + initialize: ({ input }) => ({ + phase: 'scoping' as const, + lastUserMessage: input.userMessage, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router: featureRouter as any, +}) From 87d7129f9169be639143818e0ac82a954fbe5211 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:27:29 +0200 Subject: [PATCH 15/50] feat(ts-react-chat): workflow & orchestration API routes --- .../src/routes/api.orchestration.ts | 46 +++++++++++++++ .../ts-react-chat/src/routes/api.workflow.ts | 56 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 examples/ts-react-chat/src/routes/api.orchestration.ts create mode 100644 examples/ts-react-chat/src/routes/api.workflow.ts diff --git a/examples/ts-react-chat/src/routes/api.orchestration.ts b/examples/ts-react-chat/src/routes/api.orchestration.ts new file mode 100644 index 000000000..0ee28e919 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.orchestration.ts @@ -0,0 +1,46 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + inMemoryRunStore, + resumeWorkflow, + runWorkflow, + toWorkflowSSEResponse, +} from '@tanstack/ai-orchestration' +import { featureOrchestrator } from '@/lib/workflows/orchestrator' + +const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) + +export const Route = createFileRoute('/api/orchestration')({ + server: { + handlers: { + POST: async ({ request }) => { + const body = (await request.json()) as { + input?: { userMessage: string } + runId?: string + approval?: { approvalId: string; approved: boolean } + } + + if (body.approval && body.runId) { + return toWorkflowSSEResponse( + resumeWorkflow({ + runId: body.runId, + runStore, + approval: body.approval, + }), + ) + } + + if (!body.input) { + return new Response('input required', { status: 400 }) + } + + return toWorkflowSSEResponse( + runWorkflow({ + workflow: featureOrchestrator as any, + input: body.input, + runStore, + }), + ) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.workflow.ts b/examples/ts-react-chat/src/routes/api.workflow.ts new file mode 100644 index 000000000..f4e7d23ca --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.workflow.ts @@ -0,0 +1,56 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + inMemoryRunStore, + resumeWorkflow, + runWorkflow, + toWorkflowSSEResponse, +} from '@tanstack/ai-orchestration' +import { articleWorkflow } from '@/lib/workflows/article-workflow' + +// Process-local store. Survives across requests; lost on restart. +const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) + +export const Route = createFileRoute('/api/workflow')({ + server: { + handlers: { + POST: async ({ request }) => { + const body = (await request.json()) as { + input?: { topic: string } + runId?: string + approval?: { approvalId: string; approved: boolean } + abort?: boolean + } + + if (body.abort && body.runId) { + // v1: abort signal plumbing TODO. No-op response. + return new Response(null, { status: 204 }) + } + + if (body.approval && body.runId) { + return toWorkflowSSEResponse( + resumeWorkflow({ + runId: body.runId, + runStore, + approval: { + approvalId: body.approval.approvalId, + approved: body.approval.approved, + }, + }), + ) + } + + if (!body.input) { + return new Response('input required', { status: 400 }) + } + + return toWorkflowSSEResponse( + runWorkflow({ + workflow: articleWorkflow as any, + input: body.input, + runStore, + }), + ) + }, + }, + }, +}) From cf11d43c9a9be9edb5e5c1078b88d58a45fc70a0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:30:40 +0200 Subject: [PATCH 16/50] feat(ts-react-chat): workflow + orchestration demo pages --- .../ts-react-chat/src/components/Header.tsx | 38 ++++- .../src/components/StateInspector.tsx | 10 ++ .../src/components/WorkflowTimeline.tsx | 64 ++++++++ .../src/routes/orchestration.tsx | 135 ++++++++++++++++ .../ts-react-chat/src/routes/workflow.tsx | 148 ++++++++++++++++++ 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 examples/ts-react-chat/src/components/StateInspector.tsx create mode 100644 examples/ts-react-chat/src/components/WorkflowTimeline.tsx create mode 100644 examples/ts-react-chat/src/routes/orchestration.tsx create mode 100644 examples/ts-react-chat/src/routes/workflow.tsx diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 4cd9fc4d8..8b0dde409 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -5,12 +5,14 @@ import { Braces, FileAudio, FileText, + GitBranch, Guitar, Home, Image, Menu, Mic, Music, + Network, Video, X, } from 'lucide-react' @@ -188,15 +190,47 @@ export default function Header() { setIsOpen(false)} - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" activeProps={{ className: - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', }} > Voice Chat (Realtime) + +
+ +

+ Orchestration +

+ + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Article Workflow + + + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Feature Orchestrator + diff --git a/examples/ts-react-chat/src/components/StateInspector.tsx b/examples/ts-react-chat/src/components/StateInspector.tsx new file mode 100644 index 000000000..c2c102840 --- /dev/null +++ b/examples/ts-react-chat/src/components/StateInspector.tsx @@ -0,0 +1,10 @@ +export function StateInspector(props: { state: unknown }) { + return ( +
+
State
+
+        {JSON.stringify(props.state ?? {}, null, 2)}
+      
+
+ ) +} diff --git a/examples/ts-react-chat/src/components/WorkflowTimeline.tsx b/examples/ts-react-chat/src/components/WorkflowTimeline.tsx new file mode 100644 index 000000000..185a01163 --- /dev/null +++ b/examples/ts-react-chat/src/components/WorkflowTimeline.tsx @@ -0,0 +1,64 @@ +import type { WorkflowStep } from '@tanstack/ai-client' + +export function WorkflowTimeline(props: { + steps: Array + currentStep: WorkflowStep | null + currentText?: string +}) { + return ( +
+
Timeline
+ {props.steps.length === 0 && ( +
No steps yet.
+ )} + {props.steps.map((step) => { + const active = props.currentStep?.stepId === step.stepId + return ( +
+
+ + {step.status === 'finished' + ? 'OK' + : step.status === 'failed' + ? 'X' + : '...'} + + {step.stepName} + {step.stepType && ( + [{step.stepType}] + )} + {step.finishedAt && step.startedAt && ( + + {step.finishedAt - step.startedAt}ms + + )} +
+ {active && props.currentText && ( +
+                {props.currentText}
+              
+ )} + {step.status === 'finished' && step.result !== undefined && ( +
+ + result + +
+                  {JSON.stringify(step.result, null, 2)}
+                
+
+ )} +
+ ) + })} +
+ ) +} diff --git a/examples/ts-react-chat/src/routes/orchestration.tsx b/examples/ts-react-chat/src/routes/orchestration.tsx new file mode 100644 index 000000000..866b0a517 --- /dev/null +++ b/examples/ts-react-chat/src/routes/orchestration.tsx @@ -0,0 +1,135 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { useOrchestration } from '@tanstack/ai-react' +import { StateInspector } from '@/components/StateInspector' +import { WorkflowTimeline } from '@/components/WorkflowTimeline' + +export const Route = createFileRoute('/orchestration')({ + component: OrchestrationPage, +}) + +function OrchestrationPage() { + const [message, setMessage] = useState( + 'add a /metrics endpoint to my Express app', + ) + + const orch = useOrchestration<{ userMessage: string }, unknown, unknown>({ + connection: { + connect: async function* (body: unknown) { + const response = await fetch('/api/orchestration', { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + if (!response.ok) { + throw new Error( + `HTTP error! status: ${response.status} ${response.statusText}`, + ) + } + const reader = response.body?.getReader() + if (!reader) throw new Error('Response body is not readable') + const decoder = new TextDecoder() + let buffer = '' + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + const data = trimmed.startsWith('data: ') + ? trimmed.slice(6) + : trimmed + if (data === '[DONE]') continue + try { + yield JSON.parse(data) + } catch { + // skip malformed chunks + } + } + } + if (buffer.trim()) { + const data = buffer.trim().startsWith('data: ') + ? buffer.trim().slice(6) + : buffer.trim() + try { + yield JSON.parse(data) + } catch { + // skip + } + } + } finally { + reader.releaseLock() + } + }, + } as any, + }) + + return ( +
+

Feature Orchestrator

+ +
+ setMessage(e.target.value)} + className="flex-1 border rounded px-2 py-1" + placeholder="Describe what you want" + disabled={orch.status === 'running' || orch.status === 'paused'} + /> + +
+ + {orch.pendingApproval && ( +
+
{orch.pendingApproval.title}
+ {orch.pendingApproval.description && ( +
+ {orch.pendingApproval.description} +
+ )} +
+ + +
+
+ )} + +
+ + +
+ + {orch.error && ( +
+
Error
+
{orch.error.message}
+
+ )} +
+ ) +} diff --git a/examples/ts-react-chat/src/routes/workflow.tsx b/examples/ts-react-chat/src/routes/workflow.tsx new file mode 100644 index 000000000..2aea7f4cf --- /dev/null +++ b/examples/ts-react-chat/src/routes/workflow.tsx @@ -0,0 +1,148 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { useWorkflow } from '@tanstack/ai-react' +import { StateInspector } from '@/components/StateInspector' +import { WorkflowTimeline } from '@/components/WorkflowTimeline' + +export const Route = createFileRoute('/workflow')({ + component: WorkflowPage, +}) + +function WorkflowPage() { + const [topic, setTopic] = useState('the cultural history of pufferfish') + + const wf = useWorkflow<{ topic: string }, unknown, unknown>({ + connection: { + connect: async function* (body: unknown) { + const response = await fetch('/api/workflow', { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + if (!response.ok) { + throw new Error( + `HTTP error! status: ${response.status} ${response.statusText}`, + ) + } + const reader = response.body?.getReader() + if (!reader) throw new Error('Response body is not readable') + const decoder = new TextDecoder() + let buffer = '' + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + const data = trimmed.startsWith('data: ') + ? trimmed.slice(6) + : trimmed + if (data === '[DONE]') continue + try { + yield JSON.parse(data) + } catch { + // skip malformed chunks + } + } + } + if (buffer.trim()) { + const data = buffer.trim().startsWith('data: ') + ? buffer.trim().slice(6) + : buffer.trim() + try { + yield JSON.parse(data) + } catch { + // skip + } + } + } finally { + reader.releaseLock() + } + }, + } as any, + }) + + return ( +
+

Article Workflow

+ +
+ setTopic(e.target.value)} + className="flex-1 border rounded px-2 py-1" + placeholder="Topic" + disabled={wf.status === 'running' || wf.status === 'paused'} + /> + + {(wf.status === 'running' || wf.status === 'paused') && ( + + )} +
+ + {wf.pendingApproval && ( +
+
{wf.pendingApproval.title}
+ {wf.pendingApproval.description && ( +
{wf.pendingApproval.description}
+ )} +
+ + +
+
+ )} + +
+ + +
+ + {wf.status === 'finished' && wf.steps.length > 0 && ( +
+
Done
+
+            {JSON.stringify(wf.steps.at(-1)?.result, null, 2)}
+          
+
+ )} + + {wf.error && ( +
+
Error
+
{wf.error.message}
+
+ )} +
+ ) +} From 7b0743298d289c26b3b1941f919009fc020a7019 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:32:56 +0200 Subject: [PATCH 17/50] test(ai-orchestration): engine smoke tests --- .../tests/engine.smoke.test.ts | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 packages/typescript/ai-orchestration/tests/engine.smoke.test.ts diff --git a/packages/typescript/ai-orchestration/tests/engine.smoke.test.ts b/packages/typescript/ai-orchestration/tests/engine.smoke.test.ts new file mode 100644 index 000000000..43826d3f6 --- /dev/null +++ b/packages/typescript/ai-orchestration/tests/engine.smoke.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + approve, + defineAgent, + defineWorkflow, + inMemoryRunStore, + runWorkflow, +} from '../src' + +describe('engine smoke', () => { + it('runs a single-agent workflow end-to-end', async () => { + const echo = defineAgent({ + name: 'echo', + input: z.object({ msg: z.string() }), + output: z.object({ echoed: z.string() }), + run: async ({ input }) => ({ echoed: input.msg.toUpperCase() }), + }) + + const wf = defineWorkflow({ + name: 'echo-wf', + input: z.object({ msg: z.string() }), + output: z.object({ echoed: z.string() }), + state: z.object({}).default({}), + agents: { echo }, + run: async function* ({ input, agents }) { + const r = yield* agents.echo({ msg: input.msg }) + return r + }, + }) + + const events: Array = [] + for await (const c of runWorkflow({ + workflow: wf as any, + input: { msg: 'hello' }, + runStore: inMemoryRunStore(), + })) { + events.push(c) + } + + const types = events.map((e) => (e as { type: string }).type) + expect(types).toContain('RUN_STARTED') + expect(types).toContain('STATE_SNAPSHOT') + expect(types).toContain('STEP_STARTED') + expect(types).toContain('STEP_FINISHED') + expect(types).toContain('RUN_FINISHED') + + const stepFinished = events.find( + (e) => (e as { type: string }).type === 'STEP_FINISHED', + ) as { content: unknown } + expect(stepFinished.content).toEqual({ echoed: 'HELLO' }) + }) + + it('emits STATE_DELTA on state mutations between yields', async () => { + const setter = defineAgent({ + name: 'setter', + output: z.object({ val: z.number() }), + run: async () => ({ val: 42 }), + }) + + const wf = defineWorkflow({ + name: 'state-wf', + input: z.object({}).default({}), + output: z.object({}).default({}), + state: z.object({ counter: z.number().default(0) }), + agents: { setter }, + run: async function* ({ state, agents }) { + const r = yield* agents.setter({}) + state.counter = r.val + return {} + }, + }) + + const events: Array = [] + for await (const c of runWorkflow({ + workflow: wf as any, + input: {}, + runStore: inMemoryRunStore(), + })) { + events.push(c) + } + + const delta = events.find( + (e) => (e as { type: string }).type === 'STATE_DELTA', + ) as { delta: Array<{ op: string; path: string; value: unknown }> } + expect(delta).toBeDefined() + expect(delta.delta).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: '/counter', + value: 42, + }), + ]), + ) + }) + + it('pauses on approval — stream ends after approval-requested, RUN_FINISHED not emitted', async () => { + const wf = defineWorkflow({ + name: 'approval-wf', + input: z.object({}).default({}), + output: z.object({ ok: z.boolean() }), + state: z.object({}).default({}), + agents: {}, + run: async function* () { + const d = yield* approve({ title: 'go?' }) + return { ok: d.approved } + }, + }) + + const store = inMemoryRunStore() + const events: Array = [] + for await (const c of runWorkflow({ + workflow: wf as any, + input: {}, + runStore: store, + })) { + events.push(c) + } + + const types = events.map((e) => (e as { type: string }).type) + expect(types).toContain('STEP_STARTED') + expect( + events.some( + (e) => + (e as { type: string; name?: string }).type === 'CUSTOM' && + (e as { name?: string }).name === 'approval-requested', + ), + ).toBe(true) + // Stream ended at the approval pause. + expect(types).not.toContain('RUN_FINISHED') + + // The live generator should still be retrievable from the store for resume. + // We don't inspect the live map directly (private); we verify the store + // can serve a get() of the run state with status 'paused'. + // Note: runId isn't returned to the test directly. Find from RUN_STARTED. + const runStarted = events.find( + (e) => (e as { type: string }).type === 'RUN_STARTED', + ) as { runId: string } + expect(runStarted.runId).toBeTruthy() + const runState = await store.get(runStarted.runId) + expect(runState).toBeDefined() + expect(runState!.status).toBe('paused') + expect(runState!.pendingApproval?.title).toBe('go?') + }) +}) From de7f4422c16ef80edfe920c47a87b3489eb503e0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:51:09 +0200 Subject: [PATCH 18/50] refactor(ai-orchestration): collapse runWorkflow/resumeWorkflow into single entry point --- .../src/engine/run-workflow.ts | 72 +++++++++++-------- .../typescript/ai-orchestration/src/index.ts | 3 +- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts index aecf6d3a3..81ae4ce62 100644 --- a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts +++ b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts @@ -25,10 +25,11 @@ import type { InMemoryRunStore } from '../run-store/in-memory' export interface RunWorkflowOptions { workflow: AnyWorkflowDefinition - input: unknown runStore: InMemoryRunStore - /** Optional: provide an existing runId (used for resume). If absent, generated. */ + /** First-call: provide `input`. Resume-call: provide `runId` + `approval`. */ + input?: unknown runId?: string + approval?: ApprovalResult /** Optional: external abort signal. */ signal?: AbortSignal /** Optional: thread ID for client-side correlation. */ @@ -76,16 +77,34 @@ function errorMessage(err: unknown): string { } /** - * Run a workflow to completion or pause point. Returns an AsyncIterable of - * StreamChunk that the caller pipes to SSE. + * Run a workflow to completion or pause point (start or resume). Returns an + * AsyncIterable of StreamChunk that the caller pipes to SSE. + * + * - Start call: provide `workflow`, `input`, and `runStore`. + * - Resume call: provide `workflow`, `runId`, `approval`, and `runStore`. * * Pause semantics: when the user code yields an `approval` descriptor, the * engine emits `approval-requested`, persists run state, stores the live - * generator handle in `runStore.setLive`, then ends the stream. Resume is a - * separate call to `resumeWorkflow`. + * generator handle in `runStore.setLive`, then ends the stream. The client + * resumes by calling `runWorkflow` again with `runId` and `approval`. */ export async function* runWorkflow( options: RunWorkflowOptions, +): AsyncIterable { + if (options.runId && options.approval) { + yield* resumeRun(options.runId, options.runStore, options.approval) + return + } + if (options.input === undefined) { + throw new Error( + 'runWorkflow: either `input` or both `runId` and `approval` must be provided', + ) + } + yield* startRun(options as RunWorkflowOptions & { input: unknown }) +} + +async function* startRun( + options: RunWorkflowOptions & { input: unknown }, ): AsyncIterable { const runId = options.runId ?? generateId('run') const abortController = new AbortController() @@ -321,36 +340,27 @@ export async function* runWorkflow( } } -/** - * Resume a paused workflow with an approval response. Returns the SSE stream - * for the resumed segment. - * - * v1 limitation: this only supports straight-line continuation after the - * approval. If the user code yields more agent/nested-workflow descriptors - * after the approval, those will fail. Refactor into a class with shared - * dispatch loop in v2. - */ -export async function* resumeWorkflow(args: { - runId: string - runStore: InMemoryRunStore - approval: ApprovalResult -}): AsyncIterable { - const live = args.runStore.getLive(args.runId) +async function* resumeRun( + runId: string, + runStore: InMemoryRunStore, + approval: ApprovalResult, +): AsyncIterable { + const live = runStore.getLive(runId) if (!live) { yield runErrorEvent({ - runId: args.runId, - message: `Run ${args.runId} not found (expired or never existed)`, + runId, + message: `Run ${runId} not found (expired or never existed)`, code: 'run_lost', }) return } live.runState = { ...live.runState, status: 'running', updatedAt: Date.now() } - await args.runStore.set(args.runId, live.runState) + await runStore.set(runId, live.runState) - yield runStartedEvent({ runId: args.runId }) + yield runStartedEvent({ runId }) - const nextValue: unknown = args.approval + const nextValue: unknown = approval let prevState = snapshotState(live.runState.state) let finalOutput: unknown = undefined @@ -384,15 +394,15 @@ export async function* resumeWorkflow(args: { output: finalOutput, updatedAt: Date.now(), } - await args.runStore.set(args.runId, live.runState) - yield runFinishedEvent({ runId: args.runId }) - await args.runStore.delete(args.runId, 'finished') + await runStore.set(runId, live.runState) + yield runFinishedEvent({ runId }) + await runStore.delete(runId, 'finished') } catch (err) { yield runErrorEvent({ - runId: args.runId, + runId, message: errorMessage(err), code: 'error', }) - await args.runStore.delete(args.runId, 'error') + await runStore.delete(runId, 'error') } } diff --git a/packages/typescript/ai-orchestration/src/index.ts b/packages/typescript/ai-orchestration/src/index.ts index 82bcd1358..96222aa37 100644 --- a/packages/typescript/ai-orchestration/src/index.ts +++ b/packages/typescript/ai-orchestration/src/index.ts @@ -16,9 +16,8 @@ export { retry } from './primitives/retry' export type { RetryOptions } from './primitives/retry' // ===== Server-side run ===== -export { runWorkflow, resumeWorkflow } from './engine/run-workflow' +export { runWorkflow } from './engine/run-workflow' export type { RunWorkflowOptions } from './engine/run-workflow' -export { toWorkflowSSEResponse } from './server' // ===== Run store ===== export { inMemoryRunStore } from './run-store/in-memory' From 2d273ac9dbb97c67131530d9471315cb4cc7a592 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:51:29 +0200 Subject: [PATCH 19/50] refactor(ai-orchestration): drop toWorkflowSSEResponse wrapper --- .../typescript/ai-orchestration/src/server/index.ts | 1 - .../ai-orchestration/src/server/sse-response.ts | 13 ------------- 2 files changed, 14 deletions(-) delete mode 100644 packages/typescript/ai-orchestration/src/server/index.ts delete mode 100644 packages/typescript/ai-orchestration/src/server/sse-response.ts diff --git a/packages/typescript/ai-orchestration/src/server/index.ts b/packages/typescript/ai-orchestration/src/server/index.ts deleted file mode 100644 index b6e6d7177..000000000 --- a/packages/typescript/ai-orchestration/src/server/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { toWorkflowSSEResponse } from './sse-response' diff --git a/packages/typescript/ai-orchestration/src/server/sse-response.ts b/packages/typescript/ai-orchestration/src/server/sse-response.ts deleted file mode 100644 index 4a16bf811..000000000 --- a/packages/typescript/ai-orchestration/src/server/sse-response.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { toServerSentEventsResponse } from '@tanstack/ai' -import type { StreamChunk } from '@tanstack/ai' - -/** - * Convert a workflow stream into an SSE Response. Re-export of - * `toServerSentEventsResponse` for convenience and discoverability inside - * orchestration consumers. - */ -export function toWorkflowSSEResponse( - stream: AsyncIterable, -): Response { - return toServerSentEventsResponse(stream) -} From 802548d1547fcda51c5e8830564efc6b119666dc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:51:36 +0200 Subject: [PATCH 20/50] fix(ai-orchestration): make StepGenerator TNext=any so heterogeneous yield* delegation works --- packages/typescript/ai-orchestration/src/types.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai-orchestration/src/types.ts b/packages/typescript/ai-orchestration/src/types.ts index d7cb6173a..f5959b74d 100644 --- a/packages/typescript/ai-orchestration/src/types.ts +++ b/packages/typescript/ai-orchestration/src/types.ts @@ -127,7 +127,12 @@ export type StepDescriptor = | { kind: 'nested-workflow'; name: string; input: unknown; workflow: AnyWorkflowDefinition } | { kind: 'approval'; title: string; description?: string } -export type StepGenerator = Generator +// TNext is `any` so a generator with TReturn=A can `yield*` another generator +// with TReturn=B without TS rejecting the delegation. The engine sends the +// correct typed value back at each yield boundary; the type of the value is +// determined by the inner generator (e.g., `agents.writer(...)` returns +// `WriterOutput`, `approve(...)` returns `ApprovalResult`). +export type StepGenerator = Generator // ========================================== // Approval result From 0c30b6980f440553e2b19ac603d1b5020e8eff66 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:51:44 +0200 Subject: [PATCH 21/50] feat(ai-orchestration): pass agents map to orchestrator router for type-safe yields --- .../ai-orchestration/src/define/define-orchestrator.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts b/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts index 6aab3de6f..83383bdcc 100644 --- a/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts +++ b/packages/typescript/ai-orchestration/src/define/define-orchestrator.ts @@ -1,6 +1,7 @@ import { defineWorkflow } from './define-workflow' import type { AgentMap, + BoundAgents, InferSchema, SchemaInput, StepGenerator, @@ -55,6 +56,7 @@ export interface DefineOrchestratorConfig< state: TStateSchema extends SchemaInput ? InferSchema : Record + agents: BoundAgents turn: number }) => StepGenerator> } @@ -88,6 +90,7 @@ export function defineOrchestrator< const decision = yield* config.router({ input: args.input as any, state: args.state as any, + agents: args.agents, turn, }) From 32a1987249afcefd8dd24c5ac826f7e5eaf5bd58 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:51:51 +0200 Subject: [PATCH 22/50] refactor(ts-react-chat): simplify orchestrator router to be cast-free --- .../src/lib/workflows/orchestrator.ts | 154 +++++------------- 1 file changed, 43 insertions(+), 111 deletions(-) diff --git a/examples/ts-react-chat/src/lib/workflows/orchestrator.ts b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts index dc78139cc..981296fca 100644 --- a/examples/ts-react-chat/src/lib/workflows/orchestrator.ts +++ b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts @@ -2,12 +2,10 @@ import { z } from 'zod' import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { + approve, defineAgent, defineOrchestrator, defineWorkflow, - type AgentMap, - type RouterDecision, - type StepGenerator, } from '@tanstack/ai-orchestration' // ===== Schemas ===== @@ -195,140 +193,74 @@ const triageAgent = defineAgent({ // ===== Orchestrator ===== -// v1 ergonomics gap: the router runs outside the BoundAgents context so -// specific-agent types can't reach here. RouterDecision is wider -// than RouterDecision due to the contravariant `agent` key. -// Extracting the router and casting it `as any` is the v1 workaround; fix in -// v2 by threading TAgents all the way into the router signature. -function* featureRouter({ - input, - state, -}: { - input: { userMessage: string } - state: { - phase: string - spec?: { title: string; summary: string; files: Array } - result?: { - patches: Array<{ filename: string; patch: string }> - rationale: string - } - lastUserMessage: string - } -}): StepGenerator> { - // Inline triage call. The orchestrator's router runs outside the bound - // agents context (v1 ergonomics gap), so we yield raw step descriptors. - const triageDescriptor = { - kind: 'agent' as const, - name: 'triage', - input: { - userMessage: state.lastUserMessage || input.userMessage, - phase: state.phase, - hasSpec: !!state.spec, - hasResult: !!state.result, - }, - agent: triageAgent, - } - const triageResult = (yield triageDescriptor) as unknown as { - next: 'spec' | 'await-approval' | 'implement' | 'review' | 'done' - reason: string - } +export const featureOrchestrator = defineOrchestrator({ + name: 'feature-orchestrator', + input: OrchestratorInput, + output: OrchestratorOutput, + state: OrchestratorState, + agents: { + implement: implementWorkflow, + review: reviewAgent, + spec: specAgent, + triage: triageAgent, + }, + initialize: ({ input }) => ({ + phase: 'scoping' as const, + lastUserMessage: input.userMessage, + }), + router: function* ({ input, state, agents }) { + const triage = yield* agents.triage({ + userMessage: state.lastUserMessage || input.userMessage, + phase: state.phase, + hasSpec: !!state.spec, + hasResult: !!state.result, + }) - if (triageResult.next === 'done') { + if (triage.next === 'done') { + state.phase = 'done' return { - done: true as const, - output: { - phase: state.phase as 'scoping' | 'implementing' | 'review' | 'done', - result: state.result, - }, + done: true, + output: { phase: state.phase, result: state.result }, } } - if (triageResult.next === 'spec') { + if (triage.next === 'spec') { state.phase = 'scoping' - return { - agent: 'spec' as const, - input: { userMessage: state.lastUserMessage }, - } + return { agent: 'spec', input: { userMessage: state.lastUserMessage } } } - if (triageResult.next === 'await-approval') { - // yield* approve() causes a TNext mismatch inside the router generator - // (v1 ergonomics gap: approve's TNext=ApprovalResult conflicts with - // the router's TNext=RouterDecision). Yield the descriptor directly. - const approvalDescriptor = { - kind: 'approval' as const, + if (triage.next === 'await-approval') { + const approval = yield* approve({ title: 'Start implementation?', description: state.spec ? `Spec ready: "${state.spec.title}". Begin implementing?` : 'Begin implementing?', - } - const approval = (yield approvalDescriptor) as unknown as { - approved: boolean - approvalId: string - } + }) if (approval.approved) { state.phase = 'implementing' - if (!state.spec) { - throw new Error('No spec to implement') - } - return { - agent: 'implement' as const, - input: { spec: state.spec }, - } + if (!state.spec) throw new Error('No spec to implement') + return { agent: 'implement', input: { spec: state.spec } } } state.phase = 'scoping' - return { - agent: 'spec' as const, - input: { userMessage: state.lastUserMessage }, - } + return { agent: 'spec', input: { userMessage: state.lastUserMessage } } } - if (triageResult.next === 'implement') { + if (triage.next === 'implement') { state.phase = 'implementing' - if (!state.spec) { - throw new Error('No spec to implement') - } - return { - agent: 'implement' as const, - input: { spec: state.spec }, - } + if (!state.spec) throw new Error('No spec to implement') + return { agent: 'implement', input: { spec: state.spec } } } - if (triageResult.next === 'review') { + if (triage.next === 'review') { state.phase = 'review' - if (!state.result) { - throw new Error('No result to review') - } + if (!state.result) throw new Error('No result to review') return { - agent: 'review' as const, + agent: 'review', input: { result: state.result, userMessage: state.lastUserMessage }, } } - return { - done: true as const, - output: { - phase: state.phase as 'scoping' | 'implementing' | 'review' | 'done', - result: state.result, - }, - } -} - -export const featureOrchestrator = defineOrchestrator({ - name: 'feature-orchestrator', - input: OrchestratorInput, - output: OrchestratorOutput, - state: OrchestratorState, - agents: { - implement: implementWorkflow, - review: reviewAgent, - spec: specAgent, - triage: triageAgent, + state.phase = 'done' + return { done: true, output: { phase: state.phase, result: state.result } } }, - initialize: ({ input }) => ({ - phase: 'scoping' as const, - lastUserMessage: input.userMessage, - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - router: featureRouter as any, }) From a7bac73a621b88902b04d7eaeba21de88110d227 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:51:57 +0200 Subject: [PATCH 23/50] chore(ts-react-chat): use toServerSentEventsResponse directly --- .../src/routes/api.orchestration.ts | 17 +++++++---------- .../ts-react-chat/src/routes/api.workflow.ts | 17 +++++++---------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.orchestration.ts b/examples/ts-react-chat/src/routes/api.orchestration.ts index 0ee28e919..29f03df97 100644 --- a/examples/ts-react-chat/src/routes/api.orchestration.ts +++ b/examples/ts-react-chat/src/routes/api.orchestration.ts @@ -1,10 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' -import { - inMemoryRunStore, - resumeWorkflow, - runWorkflow, - toWorkflowSSEResponse, -} from '@tanstack/ai-orchestration' +import { inMemoryRunStore, runWorkflow } from '@tanstack/ai-orchestration' +import { toServerSentEventsResponse } from '@tanstack/ai' import { featureOrchestrator } from '@/lib/workflows/orchestrator' const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) @@ -20,8 +16,9 @@ export const Route = createFileRoute('/api/orchestration')({ } if (body.approval && body.runId) { - return toWorkflowSSEResponse( - resumeWorkflow({ + return toServerSentEventsResponse( + runWorkflow({ + workflow: featureOrchestrator, runId: body.runId, runStore, approval: body.approval, @@ -33,9 +30,9 @@ export const Route = createFileRoute('/api/orchestration')({ return new Response('input required', { status: 400 }) } - return toWorkflowSSEResponse( + return toServerSentEventsResponse( runWorkflow({ - workflow: featureOrchestrator as any, + workflow: featureOrchestrator, input: body.input, runStore, }), diff --git a/examples/ts-react-chat/src/routes/api.workflow.ts b/examples/ts-react-chat/src/routes/api.workflow.ts index f4e7d23ca..7c74edc76 100644 --- a/examples/ts-react-chat/src/routes/api.workflow.ts +++ b/examples/ts-react-chat/src/routes/api.workflow.ts @@ -1,10 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' -import { - inMemoryRunStore, - resumeWorkflow, - runWorkflow, - toWorkflowSSEResponse, -} from '@tanstack/ai-orchestration' +import { inMemoryRunStore, runWorkflow } from '@tanstack/ai-orchestration' +import { toServerSentEventsResponse } from '@tanstack/ai' import { articleWorkflow } from '@/lib/workflows/article-workflow' // Process-local store. Survives across requests; lost on restart. @@ -27,8 +23,9 @@ export const Route = createFileRoute('/api/workflow')({ } if (body.approval && body.runId) { - return toWorkflowSSEResponse( - resumeWorkflow({ + return toServerSentEventsResponse( + runWorkflow({ + workflow: articleWorkflow, runId: body.runId, runStore, approval: { @@ -43,9 +40,9 @@ export const Route = createFileRoute('/api/workflow')({ return new Response('input required', { status: 400 }) } - return toWorkflowSSEResponse( + return toServerSentEventsResponse( runWorkflow({ - workflow: articleWorkflow as any, + workflow: articleWorkflow, input: body.input, runStore, }), From 5c2c310a2ba4b6159b18e6a6a6b7127f10346563 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:00:12 +0200 Subject: [PATCH 24/50] feat(ai-orchestration): ok/fail result helpers --- .../src/lib/workflows/article-workflow.ts | 10 ++++++---- .../typescript/ai-orchestration/src/index.ts | 1 + .../typescript/ai-orchestration/src/result.ts | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 packages/typescript/ai-orchestration/src/result.ts diff --git a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts index 6e9cead12..fb64b5b5c 100644 --- a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts +++ b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts @@ -5,6 +5,8 @@ import { approve, defineAgent, defineWorkflow, + fail, + ok, } from '@tanstack/ai-orchestration' // ===== Schemas ===== @@ -126,14 +128,14 @@ export const articleWorkflow = defineWorkflow({ state.legalReview = legal if (legal.verdict === 'block') { state.phase = 'done' - return { ok: false as const, reason: `legal: ${legal.findings.join('; ')}` } + return fail(`legal: ${legal.findings.join('; ')}`) } const skeptic = yield* agents.skeptic({ draft }) state.skepticReview = skeptic if (skeptic.verdict === 'block') { state.phase = 'done' - return { ok: false as const, reason: `skeptic: ${skeptic.findings.join('; ')}` } + return fail(`skeptic: ${skeptic.findings.join('; ')}`) } state.phase = 'awaiting-approval' @@ -143,7 +145,7 @@ export const articleWorkflow = defineWorkflow({ }) if (!decision.approved) { state.phase = 'done' - return { ok: false as const, reason: 'user denied' } + return fail('user denied') } state.phase = 'editing' @@ -153,6 +155,6 @@ export const articleWorkflow = defineWorkflow({ }) state.draft = final state.phase = 'done' - return { ok: true as const, article: final } + return ok({ article: final }) }, }) diff --git a/packages/typescript/ai-orchestration/src/index.ts b/packages/typescript/ai-orchestration/src/index.ts index 96222aa37..8734147f0 100644 --- a/packages/typescript/ai-orchestration/src/index.ts +++ b/packages/typescript/ai-orchestration/src/index.ts @@ -14,6 +14,7 @@ export { approve } from './primitives/approve' export type { ApproveOptions } from './primitives/approve' export { retry } from './primitives/retry' export type { RetryOptions } from './primitives/retry' +export { fail, ok } from './result' // ===== Server-side run ===== export { runWorkflow } from './engine/run-workflow' diff --git a/packages/typescript/ai-orchestration/src/result.ts b/packages/typescript/ai-orchestration/src/result.ts new file mode 100644 index 000000000..503175652 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/result.ts @@ -0,0 +1,19 @@ +/** + * Tagged result helpers for workflows that return discriminated success/failure + * unions. Avoids `as const` casts at every return site. + * + * return ok({ article: final }) // { ok: true; article: Draft } + * return fail(`legal: ${reason}`) // { ok: false; reason: string } + */ + +export function ok>( + data: T, +): { ok: true } & T { + return { ok: true, ...data } +} + +export function fail( + reason: TReason, +): { ok: false; reason: TReason } { + return { ok: false, reason } +} From 400dc3d5dd2230de88fe019a70b2c2f9704218b0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:09:31 +0200 Subject: [PATCH 25/50] refactor(ai-orchestration): rename ok to succeed Rename the result helper `ok()` to `succeed()` for clarity. The name `succeed` reads better alongside `fail` and avoids shadowing the `Response.ok` DOM property name in server contexts. --- .../src/lib/workflows/article-workflow.ts | 5 ++--- packages/typescript/ai-orchestration/src/index.ts | 10 +++++++--- packages/typescript/ai-orchestration/src/result.ts | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts index fb64b5b5c..ca0fa4958 100644 --- a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts +++ b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts @@ -6,7 +6,7 @@ import { defineAgent, defineWorkflow, fail, - ok, + succeed, } from '@tanstack/ai-orchestration' // ===== Schemas ===== @@ -117,7 +117,6 @@ export const articleWorkflow = defineWorkflow({ skeptic: reviewerFor('skeptic'), editor, }, - initialize: () => ({ phase: 'drafting' as const }), run: async function* ({ input, state, agents }) { state.phase = 'drafting' const draft = yield* agents.writer({ topic: input.topic }) @@ -155,6 +154,6 @@ export const articleWorkflow = defineWorkflow({ }) state.draft = final state.phase = 'done' - return ok({ article: final }) + return succeed({ article: final }) }, }) diff --git a/packages/typescript/ai-orchestration/src/index.ts b/packages/typescript/ai-orchestration/src/index.ts index 8734147f0..d5ed3571f 100644 --- a/packages/typescript/ai-orchestration/src/index.ts +++ b/packages/typescript/ai-orchestration/src/index.ts @@ -1,24 +1,28 @@ // ===== Definitions ===== export { defineAgent } from './define/define-agent' export type { DefineAgentConfig } from './define/define-agent' -export { defineWorkflow } from './define/define-workflow' -export type { DefineWorkflowConfig } from './define/define-workflow' export { defineOrchestrator } from './define/define-orchestrator' export type { DefineOrchestratorConfig, RouterDecision, } from './define/define-orchestrator' +export { defineRouter } from './define/define-router' +export type { RouterConfig } from './define/define-router' +export { defineWorkflow } from './define/define-workflow' +export type { DefineWorkflowConfig } from './define/define-workflow' // ===== Generator primitives ===== export { approve } from './primitives/approve' export type { ApproveOptions } from './primitives/approve' export { retry } from './primitives/retry' export type { RetryOptions } from './primitives/retry' -export { fail, ok } from './result' +export { fail, succeed } from './result' // ===== Server-side run ===== export { runWorkflow } from './engine/run-workflow' export type { RunWorkflowOptions } from './engine/run-workflow' +export { handleWorkflowRequest } from './server' +export type { HandleWorkflowRequestOptions } from './server' // ===== Run store ===== export { inMemoryRunStore } from './run-store/in-memory' diff --git a/packages/typescript/ai-orchestration/src/result.ts b/packages/typescript/ai-orchestration/src/result.ts index 503175652..944494cf6 100644 --- a/packages/typescript/ai-orchestration/src/result.ts +++ b/packages/typescript/ai-orchestration/src/result.ts @@ -2,11 +2,11 @@ * Tagged result helpers for workflows that return discriminated success/failure * unions. Avoids `as const` casts at every return site. * - * return ok({ article: final }) // { ok: true; article: Draft } - * return fail(`legal: ${reason}`) // { ok: false; reason: string } + * return succeed({ article: final }) // { ok: true; article: Draft } + * return fail(`legal: ${reason}`) // { ok: false; reason: string } */ -export function ok>( +export function succeed>( data: T, ): { ok: true } & T { return { ok: true, ...data } From 58be487d07079420de5c7f3fc241cc4062beadd3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:09:39 +0200 Subject: [PATCH 26/50] feat(ai-orchestration): add defineRouter helper for extracted routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `defineRouter(config, fn)` — a phantom-config wrapper that captures generic type parameters from a shared config object so users can extract orchestrator routers as named functions without losing type inference. --- .../src/define/define-router.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/define/define-router.ts diff --git a/packages/typescript/ai-orchestration/src/define/define-router.ts b/packages/typescript/ai-orchestration/src/define/define-router.ts new file mode 100644 index 000000000..74bd5616d --- /dev/null +++ b/packages/typescript/ai-orchestration/src/define/define-router.ts @@ -0,0 +1,59 @@ +import type { + AgentMap, + BoundAgents, + InferSchema, + SchemaInput, + StepGenerator, +} from '../types' +import type { RouterDecision } from './define-orchestrator' + +/** + * Configuration shape used to derive router argument types. Pass the same + * config object to `defineRouter` and `defineOrchestrator` so types align. + */ +export interface RouterConfig< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TStateSchema extends SchemaInput | undefined, + TAgents extends AgentMap, +> { + agents: TAgents + input?: TInputSchema + output?: TOutputSchema + state?: TStateSchema +} + +/** + * Type-preserving wrapper for orchestrator router functions. Lets you define + * the router outside the `defineOrchestrator(...)` call site without losing + * inference on `input`, `state`, or `agents`. + * + * const config = { input, output, state, agents } + * const myRouter = defineRouter(config, function* ({ input, state, agents }) { + * const triage = yield* agents.triage({ ... }) // fully typed + * return { agent: 'spec', input: { ... } } + * }) + * defineOrchestrator({ ...config, router: myRouter }) + * + * The first `_config` argument is used only for type inference — the runtime + * ignores it. This is the standard "phantom config" pattern for capturing + * generic type parameters. + */ +export function defineRouter< + TInputSchema extends SchemaInput | undefined, + TOutputSchema extends SchemaInput | undefined, + TStateSchema extends SchemaInput | undefined, + TAgents extends AgentMap, +>( + _config: RouterConfig, + router: (args: { + agents: BoundAgents + input: TInputSchema extends SchemaInput ? InferSchema : unknown + state: TStateSchema extends SchemaInput + ? InferSchema + : Record + turn: number + }) => StepGenerator>, +) { + return router +} From aad5d98826b4dae5e4dff1e0f29eb3516ec2cb01 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:10:06 +0200 Subject: [PATCH 27/50] refactor(ts-react-chat): drop redundant initialize and use defineRouter Remove `phase: 'scoping' as const` from the orchestrator initialize since the schema default covers it. Extract the orchestrator router using the new `defineRouter` helper to demonstrate zero-cast extraction of a named router function. --- .../src/lib/workflows/orchestrator.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/examples/ts-react-chat/src/lib/workflows/orchestrator.ts b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts index 981296fca..6bbe8f18f 100644 --- a/examples/ts-react-chat/src/lib/workflows/orchestrator.ts +++ b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts @@ -5,6 +5,7 @@ import { approve, defineAgent, defineOrchestrator, + defineRouter, defineWorkflow, } from '@tanstack/ai-orchestration' @@ -193,27 +194,26 @@ const triageAgent = defineAgent({ // ===== Orchestrator ===== -export const featureOrchestrator = defineOrchestrator({ - name: 'feature-orchestrator', - input: OrchestratorInput, - output: OrchestratorOutput, - state: OrchestratorState, +const orchestratorConfig = { agents: { implement: implementWorkflow, review: reviewAgent, spec: specAgent, triage: triageAgent, }, - initialize: ({ input }) => ({ - phase: 'scoping' as const, - lastUserMessage: input.userMessage, - }), - router: function* ({ input, state, agents }) { + input: OrchestratorInput, + output: OrchestratorOutput, + state: OrchestratorState, +} + +const featureRouter = defineRouter( + orchestratorConfig, + function* ({ agents, input, state }) { const triage = yield* agents.triage({ - userMessage: state.lastUserMessage || input.userMessage, - phase: state.phase, - hasSpec: !!state.spec, hasResult: !!state.result, + hasSpec: !!state.spec, + phase: state.phase, + userMessage: state.lastUserMessage || input.userMessage, }) if (triage.next === 'done') { @@ -231,10 +231,10 @@ export const featureOrchestrator = defineOrchestrator({ if (triage.next === 'await-approval') { const approval = yield* approve({ - title: 'Start implementation?', description: state.spec ? `Spec ready: "${state.spec.title}". Begin implementing?` : 'Begin implementing?', + title: 'Start implementation?', }) if (approval.approved) { state.phase = 'implementing' @@ -263,4 +263,11 @@ export const featureOrchestrator = defineOrchestrator({ state.phase = 'done' return { done: true, output: { phase: state.phase, result: state.result } } }, +) + +export const featureOrchestrator = defineOrchestrator({ + ...orchestratorConfig, + initialize: ({ input }) => ({ lastUserMessage: input.userMessage }), + name: 'feature-orchestrator', + router: featureRouter, }) From 95f095f08ff3614e83d91312e77999488e4490c3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:10:15 +0200 Subject: [PATCH 28/50] feat(ai-client): add endpoint shortcut for useWorkflow Add an `endpoint` option to WorkflowClientOptions (and UseWorkflowOptions) as a mutually exclusive alternative to `connection`. When `endpoint` is provided the client internally POSTs JSON and parses the SSE response, eliminating the inline fetch boilerplate and `as any` cast at every call site. --- packages/typescript/ai-client/src/index.ts | 1 + .../ai-client/src/workflow-client.ts | 68 +++++++++++++++---- .../typescript/ai-react/src/use-workflow.ts | 16 ++--- 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/packages/typescript/ai-client/src/index.ts b/packages/typescript/ai-client/src/index.ts index f09ae7f8b..47b0b6d71 100644 --- a/packages/typescript/ai-client/src/index.ts +++ b/packages/typescript/ai-client/src/index.ts @@ -68,6 +68,7 @@ export type { WorkflowApproval, WorkflowClientOptions, WorkflowClientState, + WorkflowConnectionAdapter, WorkflowError, WorkflowStatus, WorkflowStep, diff --git a/packages/typescript/ai-client/src/workflow-client.ts b/packages/typescript/ai-client/src/workflow-client.ts index 4c8e3cebe..930ec43ae 100644 --- a/packages/typescript/ai-client/src/workflow-client.ts +++ b/packages/typescript/ai-client/src/workflow-client.ts @@ -1,7 +1,4 @@ -import type { - ConnectConnectionAdapter, - SubscribeConnectionAdapter, -} from './connection-adapters' +import { parseSSEResponse } from './sse-parser' export interface WorkflowApproval { approvalId: string @@ -46,14 +43,48 @@ export interface WorkflowClientState { steps: Array } -export interface WorkflowClientOptions { +/** + * Minimal connection adapter interface for workflows. Accepts any body, + * yields raw parsed event objects. + */ +export interface WorkflowConnectionAdapter { + connect: ( + body: unknown, + ) => AsyncIterable | Promise> +} + +type WorkflowClientOptionsBase = { /** Optional: arbitrary extra body fields to send with the start request. */ body?: Record - connection: ConnectConnectionAdapter | SubscribeConnectionAdapter onCustomEvent?: (name: string, value: Record) => void onStateChange?: (state: WorkflowClientState) => void } +/** + * Options for WorkflowClient. Provide either `endpoint` (a URL string for the + * workflow API route) or a custom `connection` adapter. These are mutually + * exclusive. + * + * // Shortcut — POST JSON to the given endpoint, parse SSE response: + * new WorkflowClient({ endpoint: '/api/workflow' }) + * + * // Advanced — bring your own adapter: + * new WorkflowClient({ connection: myAdapter }) + */ +export type WorkflowClientOptions = WorkflowClientOptionsBase & + ( + | { + /** URL of the workflow API route. Internally constructs a fetch+SSE adapter. */ + endpoint: string + connection?: never + } + | { + endpoint?: never + /** Custom connection adapter for advanced use cases. */ + connection: WorkflowConnectionAdapter + } + ) + const initialState: WorkflowClientState = { currentStep: null, currentText: '', @@ -243,16 +274,25 @@ export class WorkflowClient< } private openStream(body: Record): AsyncIterable { - const conn = this.opts.connection const fullBody = { ...this.opts.body, ...body } - if ('connect' in conn) { - return conn.connect( - fullBody as unknown as Parameters[0], - ) as AsyncIterable + if (this.opts.endpoint) { + const endpoint = this.opts.endpoint + return (async function* () { + const response = await fetch(endpoint, { + body: JSON.stringify(fullBody), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + yield* parseSSEResponse(response) + })() + } + const conn = this.opts.connection + if (!conn) { + throw new Error('WorkflowClient: either endpoint or connection must be provided') } - throw new Error( - 'Subscribe-mode connection adapters not supported for workflow client in v1', - ) + return (async function* () { + yield* await conn.connect(fullBody) + })() } private setState( diff --git a/packages/typescript/ai-react/src/use-workflow.ts b/packages/typescript/ai-react/src/use-workflow.ts index 132a810eb..0cdee0c63 100644 --- a/packages/typescript/ai-react/src/use-workflow.ts +++ b/packages/typescript/ai-react/src/use-workflow.ts @@ -25,15 +25,15 @@ export function useWorkflow< const optsRef = useRef(opts) optsRef.current = opts + // Re-create the client only when the stable connection identity changes. + // For the `endpoint` variant, the string itself is the identity. + const connectionKey = + 'endpoint' in opts ? opts.endpoint : opts.connection + const client = useMemo( - () => - new WorkflowClient({ - body: optsRef.current.body, - connection: optsRef.current.connection, - onCustomEvent: (name, value) => - optsRef.current.onCustomEvent?.(name, value), - }), - [opts.connection], + () => new WorkflowClient(optsRef.current), + // eslint-disable-next-line react-hooks/exhaustive-deps + [connectionKey], ) const [state, setState] = useState(client.state) From 247712bfe233e964a346b28ac5a542885d289734 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:10:23 +0200 Subject: [PATCH 29/50] refactor(ts-react-chat): use endpoint shortcut in demo pages Replace the 50-line inline fetch+SSE adapter with a single \`endpoint: '/api/workflow'\` (resp. \`/api/orchestration\`) option, removing the last \`as any\` cast in the demo route files. --- .../src/routes/orchestration.tsx | 54 +------------------ .../ts-react-chat/src/routes/workflow.tsx | 54 +------------------ 2 files changed, 2 insertions(+), 106 deletions(-) diff --git a/examples/ts-react-chat/src/routes/orchestration.tsx b/examples/ts-react-chat/src/routes/orchestration.tsx index 866b0a517..a12b3781f 100644 --- a/examples/ts-react-chat/src/routes/orchestration.tsx +++ b/examples/ts-react-chat/src/routes/orchestration.tsx @@ -14,59 +14,7 @@ function OrchestrationPage() { ) const orch = useOrchestration<{ userMessage: string }, unknown, unknown>({ - connection: { - connect: async function* (body: unknown) { - const response = await fetch('/api/orchestration', { - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - }) - if (!response.ok) { - throw new Error( - `HTTP error! status: ${response.status} ${response.statusText}`, - ) - } - const reader = response.body?.getReader() - if (!reader) throw new Error('Response body is not readable') - const decoder = new TextDecoder() - let buffer = '' - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - const data = trimmed.startsWith('data: ') - ? trimmed.slice(6) - : trimmed - if (data === '[DONE]') continue - try { - yield JSON.parse(data) - } catch { - // skip malformed chunks - } - } - } - if (buffer.trim()) { - const data = buffer.trim().startsWith('data: ') - ? buffer.trim().slice(6) - : buffer.trim() - try { - yield JSON.parse(data) - } catch { - // skip - } - } - } finally { - reader.releaseLock() - } - }, - } as any, + endpoint: '/api/orchestration', }) return ( diff --git a/examples/ts-react-chat/src/routes/workflow.tsx b/examples/ts-react-chat/src/routes/workflow.tsx index 2aea7f4cf..7ff5f68b2 100644 --- a/examples/ts-react-chat/src/routes/workflow.tsx +++ b/examples/ts-react-chat/src/routes/workflow.tsx @@ -12,59 +12,7 @@ function WorkflowPage() { const [topic, setTopic] = useState('the cultural history of pufferfish') const wf = useWorkflow<{ topic: string }, unknown, unknown>({ - connection: { - connect: async function* (body: unknown) { - const response = await fetch('/api/workflow', { - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - }) - if (!response.ok) { - throw new Error( - `HTTP error! status: ${response.status} ${response.statusText}`, - ) - } - const reader = response.body?.getReader() - if (!reader) throw new Error('Response body is not readable') - const decoder = new TextDecoder() - let buffer = '' - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - const data = trimmed.startsWith('data: ') - ? trimmed.slice(6) - : trimmed - if (data === '[DONE]') continue - try { - yield JSON.parse(data) - } catch { - // skip malformed chunks - } - } - } - if (buffer.trim()) { - const data = buffer.trim().startsWith('data: ') - ? buffer.trim().slice(6) - : buffer.trim() - try { - yield JSON.parse(data) - } catch { - // skip - } - } - } finally { - reader.releaseLock() - } - }, - } as any, + endpoint: '/api/workflow', }) return ( From dd4bb66d81b5d050337c33fb7713b2370cee9116 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:10:32 +0200 Subject: [PATCH 30/50] feat(ai-orchestration): add handleWorkflowRequest server helper Add a \`handleWorkflowRequest\` function that encapsulates JSON body parsing, start-vs-resume-vs-abort dispatch, and SSE response shaping. Server API routes can now delegate entirely to this helper, eliminating the \`as { ... }\` cast on the request body and the manual \`toServerSentEventsResponse\` wiring. --- .../src/server/handle-request.ts | 61 +++++++++++++++++++ .../ai-orchestration/src/server/index.ts | 2 + 2 files changed, 63 insertions(+) create mode 100644 packages/typescript/ai-orchestration/src/server/handle-request.ts create mode 100644 packages/typescript/ai-orchestration/src/server/index.ts diff --git a/packages/typescript/ai-orchestration/src/server/handle-request.ts b/packages/typescript/ai-orchestration/src/server/handle-request.ts new file mode 100644 index 000000000..c79465e82 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/server/handle-request.ts @@ -0,0 +1,61 @@ +import { toServerSentEventsResponse } from '@tanstack/ai' +import { runWorkflow } from '../engine/run-workflow' +import type { InMemoryRunStore } from '../run-store/in-memory' +import type { AnyWorkflowDefinition, ApprovalResult } from '../types' + +export interface HandleWorkflowRequestOptions { + request: Request + runStore: InMemoryRunStore + workflow: AnyWorkflowDefinition +} + +interface RequestBody { + abort?: boolean + approval?: ApprovalResult + input?: unknown + runId?: string +} + +/** + * Server entry point for workflow runs. Handles JSON parsing, mode detection + * (start vs resume vs abort), and SSE response shaping. + * + * POST: ({ request }) => handleWorkflowRequest({ + * workflow: articleWorkflow, + * runStore, + * request, + * }) + */ +export async function handleWorkflowRequest( + options: HandleWorkflowRequestOptions, +): Promise { + const body = (await options.request.json()) as RequestBody + + if (body.abort && body.runId) { + // v1: abort plumbing TODO. No-op response. + return new Response(null, { status: 204 }) + } + + if (body.approval && body.runId) { + return toServerSentEventsResponse( + runWorkflow({ + approval: body.approval, + runId: body.runId, + runStore: options.runStore, + workflow: options.workflow, + }), + ) + } + + if (body.input === undefined) { + return new Response('input required', { status: 400 }) + } + + return toServerSentEventsResponse( + runWorkflow({ + input: body.input, + runStore: options.runStore, + workflow: options.workflow, + }), + ) +} diff --git a/packages/typescript/ai-orchestration/src/server/index.ts b/packages/typescript/ai-orchestration/src/server/index.ts new file mode 100644 index 000000000..58161d1d8 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/server/index.ts @@ -0,0 +1,2 @@ +export { handleWorkflowRequest } from './handle-request' +export type { HandleWorkflowRequestOptions } from './handle-request' From 5594a91180b3933bacaca41361f57fd0ebcdfecc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:11:03 +0200 Subject: [PATCH 31/50] refactor(ts-react-chat): use handleWorkflowRequest in API routes Replace the manual body-cast, dispatch logic, and toServerSentEventsResponse wiring with a single \`handleWorkflowRequest\` call. Both api.workflow and api.orchestration routes now read like plain configuration. --- .../src/routes/api.orchestration.ts | 42 ++++----------- .../ts-react-chat/src/routes/api.workflow.ts | 51 ++++--------------- 2 files changed, 20 insertions(+), 73 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.orchestration.ts b/examples/ts-react-chat/src/routes/api.orchestration.ts index 29f03df97..cd8c4e397 100644 --- a/examples/ts-react-chat/src/routes/api.orchestration.ts +++ b/examples/ts-react-chat/src/routes/api.orchestration.ts @@ -1,6 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' -import { inMemoryRunStore, runWorkflow } from '@tanstack/ai-orchestration' -import { toServerSentEventsResponse } from '@tanstack/ai' +import { + handleWorkflowRequest, + inMemoryRunStore, +} from '@tanstack/ai-orchestration' import { featureOrchestrator } from '@/lib/workflows/orchestrator' const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) @@ -8,36 +10,12 @@ const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) export const Route = createFileRoute('/api/orchestration')({ server: { handlers: { - POST: async ({ request }) => { - const body = (await request.json()) as { - input?: { userMessage: string } - runId?: string - approval?: { approvalId: string; approved: boolean } - } - - if (body.approval && body.runId) { - return toServerSentEventsResponse( - runWorkflow({ - workflow: featureOrchestrator, - runId: body.runId, - runStore, - approval: body.approval, - }), - ) - } - - if (!body.input) { - return new Response('input required', { status: 400 }) - } - - return toServerSentEventsResponse( - runWorkflow({ - workflow: featureOrchestrator, - input: body.input, - runStore, - }), - ) - }, + POST: ({ request }) => + handleWorkflowRequest({ + request, + runStore, + workflow: featureOrchestrator, + }), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.workflow.ts b/examples/ts-react-chat/src/routes/api.workflow.ts index 7c74edc76..556d695be 100644 --- a/examples/ts-react-chat/src/routes/api.workflow.ts +++ b/examples/ts-react-chat/src/routes/api.workflow.ts @@ -1,6 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' -import { inMemoryRunStore, runWorkflow } from '@tanstack/ai-orchestration' -import { toServerSentEventsResponse } from '@tanstack/ai' +import { + handleWorkflowRequest, + inMemoryRunStore, +} from '@tanstack/ai-orchestration' import { articleWorkflow } from '@/lib/workflows/article-workflow' // Process-local store. Survives across requests; lost on restart. @@ -9,45 +11,12 @@ const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) export const Route = createFileRoute('/api/workflow')({ server: { handlers: { - POST: async ({ request }) => { - const body = (await request.json()) as { - input?: { topic: string } - runId?: string - approval?: { approvalId: string; approved: boolean } - abort?: boolean - } - - if (body.abort && body.runId) { - // v1: abort signal plumbing TODO. No-op response. - return new Response(null, { status: 204 }) - } - - if (body.approval && body.runId) { - return toServerSentEventsResponse( - runWorkflow({ - workflow: articleWorkflow, - runId: body.runId, - runStore, - approval: { - approvalId: body.approval.approvalId, - approved: body.approval.approved, - }, - }), - ) - } - - if (!body.input) { - return new Response('input required', { status: 400 }) - } - - return toServerSentEventsResponse( - runWorkflow({ - workflow: articleWorkflow, - input: body.input, - runStore, - }), - ) - }, + POST: ({ request }) => + handleWorkflowRequest({ + request, + runStore, + workflow: articleWorkflow, + }), }, }, }) From 1473ace037f8348c5e519e92c94dbe0006dbff63 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:27:37 +0200 Subject: [PATCH 32/50] refactor(ai-client): drop endpoint shortcut from WorkflowClient Remove the endpoint discriminated union from WorkflowClientOptions and WorkflowClient.openStream. Connection adapter is now the only way to connect; the endpoint shortcut moves to the public fetchWorkflowEvents helper. --- .../ai-client/src/workflow-client.ts | 53 ++++--------------- .../typescript/ai-react/src/use-workflow.ts | 15 ++++-- 2 files changed, 19 insertions(+), 49 deletions(-) diff --git a/packages/typescript/ai-client/src/workflow-client.ts b/packages/typescript/ai-client/src/workflow-client.ts index 930ec43ae..c00089e50 100644 --- a/packages/typescript/ai-client/src/workflow-client.ts +++ b/packages/typescript/ai-client/src/workflow-client.ts @@ -1,5 +1,3 @@ -import { parseSSEResponse } from './sse-parser' - export interface WorkflowApproval { approvalId: string description?: string @@ -50,41 +48,19 @@ export interface WorkflowClientState { export interface WorkflowConnectionAdapter { connect: ( body: unknown, + abortSignal?: AbortSignal, ) => AsyncIterable | Promise> } -type WorkflowClientOptionsBase = { +export interface WorkflowClientOptions { /** Optional: arbitrary extra body fields to send with the start request. */ body?: Record + /** Connection adapter for sending requests and receiving events. */ + connection: WorkflowConnectionAdapter onCustomEvent?: (name: string, value: Record) => void onStateChange?: (state: WorkflowClientState) => void } -/** - * Options for WorkflowClient. Provide either `endpoint` (a URL string for the - * workflow API route) or a custom `connection` adapter. These are mutually - * exclusive. - * - * // Shortcut — POST JSON to the given endpoint, parse SSE response: - * new WorkflowClient({ endpoint: '/api/workflow' }) - * - * // Advanced — bring your own adapter: - * new WorkflowClient({ connection: myAdapter }) - */ -export type WorkflowClientOptions = WorkflowClientOptionsBase & - ( - | { - /** URL of the workflow API route. Internally constructs a fetch+SSE adapter. */ - endpoint: string - connection?: never - } - | { - endpoint?: never - /** Custom connection adapter for advanced use cases. */ - connection: WorkflowConnectionAdapter - } - ) - const initialState: WorkflowClientState = { currentStep: null, currentText: '', @@ -273,25 +249,14 @@ export class WorkflowClient< } } - private openStream(body: Record): AsyncIterable { + private openStream( + body: Record, + abortSignal?: AbortSignal, + ): AsyncIterable { const fullBody = { ...this.opts.body, ...body } - if (this.opts.endpoint) { - const endpoint = this.opts.endpoint - return (async function* () { - const response = await fetch(endpoint, { - body: JSON.stringify(fullBody), - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - }) - yield* parseSSEResponse(response) - })() - } const conn = this.opts.connection - if (!conn) { - throw new Error('WorkflowClient: either endpoint or connection must be provided') - } return (async function* () { - yield* await conn.connect(fullBody) + yield* await conn.connect(fullBody, abortSignal) })() } diff --git a/packages/typescript/ai-react/src/use-workflow.ts b/packages/typescript/ai-react/src/use-workflow.ts index 0cdee0c63..c39a69371 100644 --- a/packages/typescript/ai-react/src/use-workflow.ts +++ b/packages/typescript/ai-react/src/use-workflow.ts @@ -1,11 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { WorkflowClient } from '@tanstack/ai-client' import type { - WorkflowClientOptions, WorkflowClientState, + WorkflowConnectionAdapter, } from '@tanstack/ai-client' -export interface UseWorkflowOptions extends WorkflowClientOptions {} +export interface UseWorkflowOptions { + /** Connection adapter (e.g. `fetchWorkflowEvents('/api/workflow')`). */ + connection: WorkflowConnectionAdapter + /** Optional: arbitrary extra body fields to send with every request. */ + body?: Record + onCustomEvent?: (name: string, value: Record) => void + onStateChange?: (state: WorkflowClientState) => void +} export interface UseWorkflowReturn< TInput = unknown, @@ -26,9 +33,7 @@ export function useWorkflow< optsRef.current = opts // Re-create the client only when the stable connection identity changes. - // For the `endpoint` variant, the string itself is the identity. - const connectionKey = - 'endpoint' in opts ? opts.endpoint : opts.connection + const connectionKey = opts.connection const client = useMemo( () => new WorkflowClient(optsRef.current), From 474b73f3f0d98fde82f38b29e62e1e6afb78d517 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:27:45 +0200 Subject: [PATCH 33/50] feat(ai-client): add fetchWorkflowEvents adapter helper Mirror fetchServerSentEvents but typed for workflow body shape (input / runId / approval). Reuses the shared readStreamLines SSE parser already in connection-adapters.ts. --- .../ai-client/src/connection-adapters.ts | 77 +++++++++++++++++++ packages/typescript/ai-client/src/index.ts | 6 +- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 91d63a146..bf9bf0c97 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -1,4 +1,5 @@ import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai' +import type { WorkflowConnectionAdapter } from './workflow-client' /** * Merge custom headers into request headers @@ -483,3 +484,79 @@ export function rpcStream( }, } } + +/** + * Options for the workflow fetch-based SSE connection adapter. + */ +export interface FetchWorkflowEventsOptions { + body?: Record + credentials?: RequestCredentials + fetchClient?: typeof globalThis.fetch + headers?: Record | Headers + signal?: AbortSignal +} + +/** + * Create a Server-Sent Events connection adapter for workflow runs. + * Mirrors `fetchServerSentEvents` but typed for workflow body shape + * (input / runId / approval) instead of chat messages. + * + * @example + * ```typescript + * useWorkflow({ connection: fetchWorkflowEvents('/api/workflow') }) + * ``` + */ +export function fetchWorkflowEvents( + url: string | (() => string), + options: + | FetchWorkflowEventsOptions + | (() => FetchWorkflowEventsOptions | Promise) = {}, +): WorkflowConnectionAdapter { + return { + async *connect(body, abortSignal) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = + typeof options === 'function' ? await options() : options + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...mergeHeaders(resolvedOptions.headers), + } + + const requestBody: Record = { + ...(typeof body === 'object' && body !== null + ? (body as Record) + : {}), + ...resolvedOptions.body, + } + + const fetchClient = resolvedOptions.fetchClient ?? fetch + const response = await fetchClient(resolvedUrl, { + body: JSON.stringify(requestBody), + credentials: resolvedOptions.credentials ?? 'same-origin', + headers: requestHeaders, + method: 'POST', + signal: abortSignal ?? resolvedOptions.signal, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const reader = response.body?.getReader() + if (!reader) { + throw new Error('Response body is not readable') + } + + for await (const line of readStreamLines(reader, abortSignal)) { + const data = line.startsWith('data: ') ? line.slice(6) : line + if (!data) continue + try { + yield JSON.parse(data) + } catch { + // skip malformed lines + } + } + }, + } +} diff --git a/packages/typescript/ai-client/src/index.ts b/packages/typescript/ai-client/src/index.ts index 47b0b6d71..1e3d12a3b 100644 --- a/packages/typescript/ai-client/src/index.ts +++ b/packages/typescript/ai-client/src/index.ts @@ -53,13 +53,15 @@ export type { RealtimeStateChangeCallback, } from './realtime-types' export { - fetchServerSentEvents, fetchHttpStream, - stream, + fetchServerSentEvents, + fetchWorkflowEvents, rpcStream, + stream, type ConnectConnectionAdapter, type ConnectionAdapter, type FetchConnectionOptions, + type FetchWorkflowEventsOptions, type SubscribeConnectionAdapter, } from './connection-adapters' From 2e743ac184c40b9df963e02b608887d59ea82f2e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:27:52 +0200 Subject: [PATCH 34/50] feat(ai-react): re-export fetchWorkflowEvents and FetchWorkflowEventsOptions Mirrors the existing fetchServerSentEvents re-export so the workflow import pattern is symmetric with the chat pattern. --- packages/typescript/ai-react/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai-react/src/index.ts b/packages/typescript/ai-react/src/index.ts index baf8a1b81..aa6a8004d 100644 --- a/packages/typescript/ai-react/src/index.ts +++ b/packages/typescript/ai-react/src/index.ts @@ -60,12 +60,14 @@ export type { // Re-export from ai-client for convenience export { - fetchServerSentEvents, fetchHttpStream, + fetchServerSentEvents, + fetchWorkflowEvents, stream, createChatClientOptions, type ConnectionAdapter, type FetchConnectionOptions, + type FetchWorkflowEventsOptions, type InferChatMessages, type GenerationClientState, type ImageGenerateInput, From 878dcfe705d277532d18209f1eef5834e681fe49 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:28:01 +0200 Subject: [PATCH 35/50] refactor(ai-orchestration): replace handleWorkflowRequest with parseWorkflowRequest Delete the magic do-everything handleWorkflowRequest wrapper. Replace with parseWorkflowRequest(request) which returns params to spread into runWorkflow, mirroring how chat routes pull messages + data out of JSON. --- .../typescript/ai-orchestration/src/index.ts | 4 +- .../src/server/handle-request.ts | 61 ------------------- .../ai-orchestration/src/server/index.ts | 4 +- .../src/server/parse-request.ts | 39 ++++++++++++ 4 files changed, 43 insertions(+), 65 deletions(-) delete mode 100644 packages/typescript/ai-orchestration/src/server/handle-request.ts create mode 100644 packages/typescript/ai-orchestration/src/server/parse-request.ts diff --git a/packages/typescript/ai-orchestration/src/index.ts b/packages/typescript/ai-orchestration/src/index.ts index d5ed3571f..a70fa2edd 100644 --- a/packages/typescript/ai-orchestration/src/index.ts +++ b/packages/typescript/ai-orchestration/src/index.ts @@ -21,8 +21,8 @@ export { fail, succeed } from './result' // ===== Server-side run ===== export { runWorkflow } from './engine/run-workflow' export type { RunWorkflowOptions } from './engine/run-workflow' -export { handleWorkflowRequest } from './server' -export type { HandleWorkflowRequestOptions } from './server' +export { parseWorkflowRequest } from './server' +export type { WorkflowRequestParams } from './server' // ===== Run store ===== export { inMemoryRunStore } from './run-store/in-memory' diff --git a/packages/typescript/ai-orchestration/src/server/handle-request.ts b/packages/typescript/ai-orchestration/src/server/handle-request.ts deleted file mode 100644 index c79465e82..000000000 --- a/packages/typescript/ai-orchestration/src/server/handle-request.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { toServerSentEventsResponse } from '@tanstack/ai' -import { runWorkflow } from '../engine/run-workflow' -import type { InMemoryRunStore } from '../run-store/in-memory' -import type { AnyWorkflowDefinition, ApprovalResult } from '../types' - -export interface HandleWorkflowRequestOptions { - request: Request - runStore: InMemoryRunStore - workflow: AnyWorkflowDefinition -} - -interface RequestBody { - abort?: boolean - approval?: ApprovalResult - input?: unknown - runId?: string -} - -/** - * Server entry point for workflow runs. Handles JSON parsing, mode detection - * (start vs resume vs abort), and SSE response shaping. - * - * POST: ({ request }) => handleWorkflowRequest({ - * workflow: articleWorkflow, - * runStore, - * request, - * }) - */ -export async function handleWorkflowRequest( - options: HandleWorkflowRequestOptions, -): Promise { - const body = (await options.request.json()) as RequestBody - - if (body.abort && body.runId) { - // v1: abort plumbing TODO. No-op response. - return new Response(null, { status: 204 }) - } - - if (body.approval && body.runId) { - return toServerSentEventsResponse( - runWorkflow({ - approval: body.approval, - runId: body.runId, - runStore: options.runStore, - workflow: options.workflow, - }), - ) - } - - if (body.input === undefined) { - return new Response('input required', { status: 400 }) - } - - return toServerSentEventsResponse( - runWorkflow({ - input: body.input, - runStore: options.runStore, - workflow: options.workflow, - }), - ) -} diff --git a/packages/typescript/ai-orchestration/src/server/index.ts b/packages/typescript/ai-orchestration/src/server/index.ts index 58161d1d8..371649cd9 100644 --- a/packages/typescript/ai-orchestration/src/server/index.ts +++ b/packages/typescript/ai-orchestration/src/server/index.ts @@ -1,2 +1,2 @@ -export { handleWorkflowRequest } from './handle-request' -export type { HandleWorkflowRequestOptions } from './handle-request' +export { parseWorkflowRequest } from './parse-request' +export type { WorkflowRequestParams } from './parse-request' diff --git a/packages/typescript/ai-orchestration/src/server/parse-request.ts b/packages/typescript/ai-orchestration/src/server/parse-request.ts new file mode 100644 index 000000000..0383eb7c2 --- /dev/null +++ b/packages/typescript/ai-orchestration/src/server/parse-request.ts @@ -0,0 +1,39 @@ +import type { ApprovalResult } from '../types' + +export interface WorkflowRequestParams { + approval?: ApprovalResult + input?: unknown + runId?: string +} + +interface RawBody { + abort?: boolean + approval?: ApprovalResult + input?: unknown + runId?: string +} + +/** + * Parse a workflow run request body. Returns the params to spread into + * `runWorkflow(...)`. Mirrors how chat routes pull `messages` and `data` + * out of `request.json()`. + * + * @example + * ```typescript + * POST: async ({ request }) => { + * const params = await parseWorkflowRequest(request) + * const stream = runWorkflow({ workflow, runStore, ...params }) + * return toServerSentEventsResponse(stream) + * } + * ``` + */ +export async function parseWorkflowRequest( + request: Request, +): Promise { + const body = (await request.json()) as RawBody + return { + approval: body.approval, + input: body.input, + runId: body.runId, + } +} From 6253eee6471f81cc8d433d1cfbd8082d6c083984 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:28:09 +0200 Subject: [PATCH 36/50] refactor(ts-react-chat): use fetchWorkflowEvents + parseWorkflowRequest Client pages now use connection: fetchWorkflowEvents('/api/...') instead of endpoint shortcut. API routes use the 3-line parse->run->respond pattern that mirrors the existing chat route structure exactly. --- .../ts-react-chat/src/routes/api.orchestration.ts | 15 ++++++++++----- examples/ts-react-chat/src/routes/api.workflow.ts | 15 ++++++++++----- .../ts-react-chat/src/routes/orchestration.tsx | 4 ++-- examples/ts-react-chat/src/routes/workflow.tsx | 4 ++-- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.orchestration.ts b/examples/ts-react-chat/src/routes/api.orchestration.ts index cd8c4e397..86d50b337 100644 --- a/examples/ts-react-chat/src/routes/api.orchestration.ts +++ b/examples/ts-react-chat/src/routes/api.orchestration.ts @@ -1,7 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' import { - handleWorkflowRequest, inMemoryRunStore, + parseWorkflowRequest, + runWorkflow, } from '@tanstack/ai-orchestration' import { featureOrchestrator } from '@/lib/workflows/orchestrator' @@ -10,12 +12,15 @@ const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) export const Route = createFileRoute('/api/orchestration')({ server: { handlers: { - POST: ({ request }) => - handleWorkflowRequest({ - request, + POST: async ({ request }) => { + const params = await parseWorkflowRequest(request) + const stream = runWorkflow({ runStore, workflow: featureOrchestrator, - }), + ...params, + }) + return toServerSentEventsResponse(stream) + }, }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.workflow.ts b/examples/ts-react-chat/src/routes/api.workflow.ts index 556d695be..facf7aa2b 100644 --- a/examples/ts-react-chat/src/routes/api.workflow.ts +++ b/examples/ts-react-chat/src/routes/api.workflow.ts @@ -1,7 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' import { - handleWorkflowRequest, inMemoryRunStore, + parseWorkflowRequest, + runWorkflow, } from '@tanstack/ai-orchestration' import { articleWorkflow } from '@/lib/workflows/article-workflow' @@ -11,12 +13,15 @@ const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) export const Route = createFileRoute('/api/workflow')({ server: { handlers: { - POST: ({ request }) => - handleWorkflowRequest({ - request, + POST: async ({ request }) => { + const params = await parseWorkflowRequest(request) + const stream = runWorkflow({ runStore, workflow: articleWorkflow, - }), + ...params, + }) + return toServerSentEventsResponse(stream) + }, }, }, }) diff --git a/examples/ts-react-chat/src/routes/orchestration.tsx b/examples/ts-react-chat/src/routes/orchestration.tsx index a12b3781f..d8a68f920 100644 --- a/examples/ts-react-chat/src/routes/orchestration.tsx +++ b/examples/ts-react-chat/src/routes/orchestration.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useState } from 'react' -import { useOrchestration } from '@tanstack/ai-react' +import { fetchWorkflowEvents, useOrchestration } from '@tanstack/ai-react' import { StateInspector } from '@/components/StateInspector' import { WorkflowTimeline } from '@/components/WorkflowTimeline' @@ -14,7 +14,7 @@ function OrchestrationPage() { ) const orch = useOrchestration<{ userMessage: string }, unknown, unknown>({ - endpoint: '/api/orchestration', + connection: fetchWorkflowEvents('/api/orchestration'), }) return ( diff --git a/examples/ts-react-chat/src/routes/workflow.tsx b/examples/ts-react-chat/src/routes/workflow.tsx index 7ff5f68b2..35a49e871 100644 --- a/examples/ts-react-chat/src/routes/workflow.tsx +++ b/examples/ts-react-chat/src/routes/workflow.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useState } from 'react' -import { useWorkflow } from '@tanstack/ai-react' +import { fetchWorkflowEvents, useWorkflow } from '@tanstack/ai-react' import { StateInspector } from '@/components/StateInspector' import { WorkflowTimeline } from '@/components/WorkflowTimeline' @@ -12,7 +12,7 @@ function WorkflowPage() { const [topic, setTopic] = useState('the cultural history of pufferfish') const wf = useWorkflow<{ topic: string }, unknown, unknown>({ - endpoint: '/api/workflow', + connection: fetchWorkflowEvents('/api/workflow'), }) return ( From 9bbbc83960c7e3bcc23d7f83e4df59e4eb3b3dfe Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:49:36 +0200 Subject: [PATCH 37/50] fix(ai-orchestration): default-import fast-json-patch for ESM/CJS interop --- .../typescript/ai-orchestration/src/engine/state-diff.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai-orchestration/src/engine/state-diff.ts b/packages/typescript/ai-orchestration/src/engine/state-diff.ts index 863dd2bbb..b2f201cf4 100644 --- a/packages/typescript/ai-orchestration/src/engine/state-diff.ts +++ b/packages/typescript/ai-orchestration/src/engine/state-diff.ts @@ -1,6 +1,11 @@ -import { compare } from 'fast-json-patch' +import jsonpatch from 'fast-json-patch' import type { Operation } from 'fast-json-patch' +// fast-json-patch ships CJS. Vite SSR (and other strict ESM runtimes) can't +// extract named exports from a CJS module, so import the default and pull +// `compare` off it. +const { compare } = jsonpatch + /** * Snapshot a state object via structuredClone for later diffing. */ From b7e7843df23b72e840087071f6c2986e853c0acc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:53:15 +0200 Subject: [PATCH 38/50] refactor(ai-orchestration): hand-roll JSON Patch differ, drop fast-json-patch dep --- .../typescript/ai-orchestration/package.json | 3 +- .../src/engine/emit-events.ts | 2 +- .../ai-orchestration/src/engine/state-diff.ts | 87 +++++++++++++++++-- pnpm-lock.yaml | 8 -- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/packages/typescript/ai-orchestration/package.json b/packages/typescript/ai-orchestration/package.json index 54d278cfa..456d91037 100644 --- a/packages/typescript/ai-orchestration/package.json +++ b/packages/typescript/ai-orchestration/package.json @@ -32,8 +32,7 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/ai-event-client": "workspace:*", - "fast-json-patch": "^3.1.1" + "@tanstack/ai-event-client": "workspace:*" }, "peerDependencies": { "@tanstack/ai": "workspace:^" diff --git a/packages/typescript/ai-orchestration/src/engine/emit-events.ts b/packages/typescript/ai-orchestration/src/engine/emit-events.ts index 6831fd3b6..213b54d2a 100644 --- a/packages/typescript/ai-orchestration/src/engine/emit-events.ts +++ b/packages/typescript/ai-orchestration/src/engine/emit-events.ts @@ -1,5 +1,5 @@ import type { StreamChunk } from '@tanstack/ai' -import type { Operation } from 'fast-json-patch' +import type { Operation } from './state-diff' /** * Helpers that produce native AG-UI event chunks for the workflow lifecycle. diff --git a/packages/typescript/ai-orchestration/src/engine/state-diff.ts b/packages/typescript/ai-orchestration/src/engine/state-diff.ts index b2f201cf4..15ce557e0 100644 --- a/packages/typescript/ai-orchestration/src/engine/state-diff.ts +++ b/packages/typescript/ai-orchestration/src/engine/state-diff.ts @@ -1,13 +1,19 @@ -import jsonpatch from 'fast-json-patch' -import type { Operation } from 'fast-json-patch' +/** + * Minimal JSON Patch (RFC 6902) helpers for workflow state observability. + * + * Emits the three op kinds the engine needs (replace, add, remove). The + * client's tiny applier in WorkflowClient handles the same set. Move/copy/ + * test are intentionally omitted — they're never produced by a forward diff + * and the spec allows producers to use any subset. + */ -// fast-json-patch ships CJS. Vite SSR (and other strict ESM runtimes) can't -// extract named exports from a CJS module, so import the default and pull -// `compare` off it. -const { compare } = jsonpatch +export type Operation = + | { op: 'replace'; path: string; value: unknown } + | { op: 'add'; path: string; value: unknown } + | { op: 'remove'; path: string } /** - * Snapshot a state object via structuredClone for later diffing. + * Snapshot a state object for later diffing. */ export function snapshotState(state: T): T { return structuredClone(state) @@ -15,8 +21,71 @@ export function snapshotState(state: T): T { /** * Produce an RFC 6902 JSON Patch from `prev` to `next`. Empty array if no - * changes. + * changes. Recursively diffs plain objects and arrays; for arrays of + * different length, emits a single top-level `replace` rather than + * splice-style ops (simpler wire shape, sufficient for state observability). */ export function diffState(prev: T, next: T): Array { - return compare(prev as object, next as object) + return diff(prev, next, '') +} + +function diff( + prev: unknown, + next: unknown, + path: string, +): Array { + if (Object.is(prev, next)) return [] + + const prevIsObj = isObject(prev) + const nextIsObj = isObject(next) + + // One is a primitive (or null), or types disagree — replace whole node. + if (!prevIsObj || !nextIsObj || Array.isArray(prev) !== Array.isArray(next)) { + return [{ op: 'replace', path: path || '', value: next }] + } + + if (Array.isArray(prev) && Array.isArray(next)) { + // Length mismatch → replace the array. Same length → diff element-wise. + if (prev.length !== next.length) { + return [{ op: 'replace', path: path || '', value: next }] + } + const ops: Array = [] + for (let i = 0; i < prev.length; i++) { + ops.push(...diff(prev[i], next[i], `${path}/${i}`)) + } + return ops + } + + // Both are plain objects. + const prevObj = prev as Record + const nextObj = next as Record + const ops: Array = [] + const allKeys = new Set([...Object.keys(prevObj), ...Object.keys(nextObj)]) + + for (const key of allKeys) { + const subPath = `${path}/${escapeJsonPointer(key)}` + const prevHas = Object.prototype.hasOwnProperty.call(prevObj, key) + const nextHas = Object.prototype.hasOwnProperty.call(nextObj, key) + + if (prevHas && nextHas) { + ops.push(...diff(prevObj[key], nextObj[key], subPath)) + } else if (nextHas) { + ops.push({ op: 'add', path: subPath, value: nextObj[key] }) + } else { + ops.push({ op: 'remove', path: subPath }) + } + } + + return ops +} + +function isObject(value: unknown): value is object { + return value !== null && typeof value === 'object' +} + +/** + * Escape `/` and `~` per RFC 6901 (JSON Pointer). + */ +function escapeJsonPointer(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9caa0951..f931095bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1343,9 +1343,6 @@ importers: '@tanstack/ai-event-client': specifier: workspace:* version: link:../ai-event-client - fast-json-patch: - specifier: ^3.1.1 - version: 3.1.1 devDependencies: '@standard-schema/spec': specifier: ^1.1.0 @@ -8078,9 +8075,6 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -18997,8 +18991,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-patch@3.1.1: {} - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} From cefd3871c9a0b15214374b6c718e0212871d0103 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:57:36 +0200 Subject: [PATCH 39/50] fix(ai-react): stabilize useWorkflow client identity (mirror useChat memo pattern) --- .../typescript/ai-react/src/use-workflow.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/typescript/ai-react/src/use-workflow.ts b/packages/typescript/ai-react/src/use-workflow.ts index c39a69371..8102e6c84 100644 --- a/packages/typescript/ai-react/src/use-workflow.ts +++ b/packages/typescript/ai-react/src/use-workflow.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { WorkflowClient } from '@tanstack/ai-client' import type { WorkflowClientState, @@ -10,6 +10,8 @@ export interface UseWorkflowOptions { connection: WorkflowConnectionAdapter /** Optional: arbitrary extra body fields to send with every request. */ body?: Record + /** Stable identifier for this hook instance. Auto-generated if omitted. */ + id?: string onCustomEvent?: (name: string, value: Record) => void onStateChange?: (state: WorkflowClientState) => void } @@ -29,16 +31,29 @@ export function useWorkflow< TOutput = unknown, TState = unknown, >(opts: UseWorkflowOptions): UseWorkflowReturn { + const hookId = useId() + const clientId = opts.id ?? hookId + + // Track latest options so callbacks read fresh values without recreating + // the client. Mirrors useChat's pattern. const optsRef = useRef(opts) optsRef.current = opts - // Re-create the client only when the stable connection identity changes. - const connectionKey = opts.connection - + // Create the client once per hook instance. The connection adapter is + // captured at construction; passing a new `fetchWorkflowEvents(...)` + // result on every render does NOT recreate the client (which would + // orphan in-flight streams). For dynamic URLs/headers, the adapter + // accepts function-typed options. const client = useMemo( - () => new WorkflowClient(optsRef.current), - // eslint-disable-next-line react-hooks/exhaustive-deps - [connectionKey], + () => + new WorkflowClient({ + connection: optsRef.current.connection, + body: optsRef.current.body, + onCustomEvent: (name, value) => + optsRef.current.onCustomEvent?.(name, value), + onStateChange: (state) => optsRef.current.onStateChange?.(state), + }), + [clientId], ) const [state, setState] = useState(client.state) From 6256f208a0361f1d128ba3d8806b2dfeabf6bd58 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:34:10 +0200 Subject: [PATCH 40/50] fix(ai-orchestration): share dispatch loop so resume handles full descriptor set --- .../src/engine/run-workflow.ts | 181 +++++++++--------- 1 file changed, 93 insertions(+), 88 deletions(-) diff --git a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts index 81ae4ce62..4650cae9c 100644 --- a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts +++ b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts @@ -92,7 +92,7 @@ export async function* runWorkflow( options: RunWorkflowOptions, ): AsyncIterable { if (options.runId && options.approval) { - yield* resumeRun(options.runId, options.runStore, options.approval) + yield* resumeRun(options) return } if (options.input === undefined) { @@ -122,7 +122,7 @@ async function* startRun( initialState as Record, ) - let runState: RunState = { + const runState: RunState = { runId, status: 'running', workflowName: options.workflow.name, @@ -161,11 +161,78 @@ async function* startRun( const generator = options.workflow.run(args as any) live.generator = generator - options.runStore.setLive(runId, live) + yield* driveLoop({ + live, + runId, + state, + runStore: options.runStore, + threadId: options.threadId, + outputSink: options.outputSink, + abortController, + seedValue: undefined, + }) +} + +async function* resumeRun( + options: RunWorkflowOptions, +): AsyncIterable { + const runId = options.runId! + const approval = options.approval! + const live = options.runStore.getLive(runId) + if (!live) { + yield runErrorEvent({ + runId, + message: `Run ${runId} not found (expired or never existed)`, + code: 'run_lost', + }) + return + } + + live.runState = { ...live.runState, status: 'running', updatedAt: Date.now() } + await options.runStore.set(runId, live.runState) + + yield runStartedEvent({ runId, threadId: options.threadId }) + + yield* driveLoop({ + live, + runId, + state: live.runState.state as Record, + runStore: options.runStore, + threadId: options.threadId, + outputSink: options.outputSink, + abortController: live.abortController, + seedValue: approval, + }) +} + +interface DriveLoopArgs { + live: LiveRun + runId: string + /** Same reference the user generator's `args.state` holds. */ + state: Record + runStore: InMemoryRunStore + threadId?: string + outputSink?: (output: unknown) => void + abortController: AbortController + /** First value sent into `generator.next(...)`. `undefined` for start, `ApprovalResult` for resume. */ + seedValue: unknown +} + +/** + * Shared dispatch loop for both start and resume paths. Drives the generator, + * dispatches descriptor kinds, emits state deltas, and finalizes the run on + * done / error / abort / pause. + */ +async function* driveLoop( + args: DriveLoopArgs, +): AsyncIterable { + const { live, runId, state, runStore, threadId, outputSink, abortController } = + args + let prevState = snapshotState(state) - let nextValue: unknown = undefined + let nextValue: unknown = args.seedValue let finalOutput: unknown = undefined try { @@ -173,7 +240,7 @@ async function* startRun( // Drain any custom events queued by emit() before advancing the generator. while (live.pendingEvents.length > 0) yield live.pendingEvents.shift()! - const result = await generator.next(nextValue as StepDescriptor) + const result = await live.generator.next(nextValue as StepDescriptor) // Diff state that may have mutated during the user's generator step. const delta = diffState(prevState, state) @@ -201,7 +268,14 @@ async function* startRun( const { stream, output } = invokeAgent( descriptor.agent, descriptor.input, - args.emit, + (name, value) => { + live.pendingEvents.push({ + type: 'CUSTOM', + timestamp: Date.now(), + name, + value, + } as StreamChunk) + }, abortController.signal, ) @@ -217,7 +291,7 @@ async function* startRun( content: { error: serializeError(err) }, }) nextValue = undefined - const thrown = await generator.throw(err) + const thrown = await live.generator.throw(err) if (thrown.done) { finalOutput = thrown.value break @@ -246,7 +320,7 @@ async function* startRun( const nestedIter = runWorkflow({ workflow: descriptor.workflow, input: descriptor.input, - runStore: options.runStore, + runStore, signal: abortController.signal, outputSink: (o) => { nestedOutput = o @@ -254,7 +328,6 @@ async function* startRun( }) for await (const chunk of nestedIter) { - // Filter inner run boundaries so the outer run owns them. if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') { continue } @@ -288,8 +361,8 @@ async function* startRun( description: approvalDescriptor.description, }) - runState = { - ...runState, + live.runState = { + ...live.runState, status: 'paused', state, pendingApproval: { @@ -299,28 +372,27 @@ async function* startRun( }, updatedAt: Date.now(), } - live.runState = runState - await options.runStore.set(runId, runState) + await runStore.set(runId, live.runState) - // SSE stream ends here; resumeWorkflow continues after client posts approval. + // SSE stream ends here; runWorkflow continues after client posts approval. return } } // Notify the parent before we delete our store entry so the output is // accessible across the store-delete boundary. - options.outputSink?.(finalOutput) + outputSink?.(finalOutput) - runState = { - ...runState, + live.runState = { + ...live.runState, status: 'finished', state, output: finalOutput, updatedAt: Date.now(), } - await options.runStore.set(runId, runState) - yield runFinishedEvent({ runId, threadId: options.threadId }) - await options.runStore.delete(runId, 'finished') + await runStore.set(runId, live.runState) + yield runFinishedEvent({ runId, threadId }) + await runStore.delete(runId, 'finished') } catch (err) { if (abortController.signal.aborted) { yield runErrorEvent({ @@ -328,76 +400,9 @@ async function* startRun( message: 'Workflow aborted', code: 'aborted', }) - await options.runStore.delete(runId, 'aborted') + await runStore.delete(runId, 'aborted') return } - yield runErrorEvent({ - runId, - message: errorMessage(err), - code: 'error', - }) - await options.runStore.delete(runId, 'error') - } -} - -async function* resumeRun( - runId: string, - runStore: InMemoryRunStore, - approval: ApprovalResult, -): AsyncIterable { - const live = runStore.getLive(runId) - if (!live) { - yield runErrorEvent({ - runId, - message: `Run ${runId} not found (expired or never existed)`, - code: 'run_lost', - }) - return - } - - live.runState = { ...live.runState, status: 'running', updatedAt: Date.now() } - await runStore.set(runId, live.runState) - - yield runStartedEvent({ runId }) - - const nextValue: unknown = approval - let prevState = snapshotState(live.runState.state) - let finalOutput: unknown = undefined - - try { - for (;;) { - // Drain any custom events queued by emit() before advancing the generator. - while (live.pendingEvents.length > 0) yield live.pendingEvents.shift()! - - const result = await live.generator.next(nextValue as StepDescriptor) - - const delta = diffState(prevState, live.runState.state) - if (delta.length > 0) { - yield stateDeltaEvent({ delta }) - prevState = snapshotState(live.runState.state) - } - - if (result.done) { - finalOutput = result.value - break - } - - throw new Error( - 'Resume after approval supports straight-line continuation in v1; ' + - 'extract dispatch loop into a class to handle nested yields after resume.', - ) - } - - live.runState = { - ...live.runState, - status: 'finished', - output: finalOutput, - updatedAt: Date.now(), - } - await runStore.set(runId, live.runState) - yield runFinishedEvent({ runId }) - await runStore.delete(runId, 'finished') - } catch (err) { yield runErrorEvent({ runId, message: errorMessage(err), From 37e31f346376641ab33b7121c0616eafb11be1a3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:43:32 +0200 Subject: [PATCH 41/50] feat(ts-react-chat): editorial-brutalist redesign of workflow + orchestration demos --- .../src/components/StateInspector.tsx | 107 ++++- .../src/components/WorkflowTimeline.tsx | 224 ++++++++--- .../src/routes/orchestration.tsx | 369 +++++++++++++++--- .../ts-react-chat/src/routes/workflow.tsx | 318 ++++++++++++--- examples/ts-react-chat/src/styles.css | 186 ++++++++- 5 files changed, 1034 insertions(+), 170 deletions(-) diff --git a/examples/ts-react-chat/src/components/StateInspector.tsx b/examples/ts-react-chat/src/components/StateInspector.tsx index c2c102840..cb4b176fd 100644 --- a/examples/ts-react-chat/src/components/StateInspector.tsx +++ b/examples/ts-react-chat/src/components/StateInspector.tsx @@ -1,10 +1,105 @@ +import { useMemo } from 'react' + export function StateInspector(props: { state: unknown }) { + const lines = useMemo(() => syntaxHighlight(props.state ?? {}), [props.state]) + const isEmpty = + props.state === null || + props.state === undefined || + (typeof props.state === 'object' && + Object.keys(props.state as object).length === 0) + return ( -
-
State
-
-        {JSON.stringify(props.state ?? {}, null, 2)}
-      
-
+ ) } + +/** Tiny syntax highlighter for pretty-printed JSON. */ +function syntaxHighlight(value: unknown): React.ReactNode { + const text = JSON.stringify(value, null, 2) + if (!text) return null + + const pattern = + /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)|([{}[\],])/g + + const tokens: Array = [] + let cursor = 0 + let key = 0 + + for (const match of text.matchAll(pattern)) { + const start = match.index ?? 0 + if (start > cursor) tokens.push(text.slice(cursor, start)) + + const [whole, propKey, str, kw, num, punc] = match + if (propKey) { + tokens.push( + + {propKey} + , + ) + } else if (str) { + tokens.push( + + {str} + , + ) + } else if (kw) { + tokens.push( + + {kw} + , + ) + } else if (num) { + tokens.push( + + {num} + , + ) + } else if (punc) { + tokens.push( + + {punc} + , + ) + } else { + tokens.push(whole) + } + cursor = start + whole.length + } + if (cursor < text.length) tokens.push(text.slice(cursor)) + return tokens +} diff --git a/examples/ts-react-chat/src/components/WorkflowTimeline.tsx b/examples/ts-react-chat/src/components/WorkflowTimeline.tsx index 185a01163..0415ccc18 100644 --- a/examples/ts-react-chat/src/components/WorkflowTimeline.tsx +++ b/examples/ts-react-chat/src/components/WorkflowTimeline.tsx @@ -6,59 +6,181 @@ export function WorkflowTimeline(props: { currentText?: string }) { return ( -
-
Timeline
- {props.steps.length === 0 && ( -
No steps yet.
+
+
+ + {props.steps.length === 0 ? ( + + ) : ( +
    + {props.steps.map((step, i) => ( + + ))} +
)} - {props.steps.map((step) => { - const active = props.currentStep?.stepId === step.stepId - return ( -
-
- - {step.status === 'finished' - ? 'OK' - : step.status === 'failed' - ? 'X' - : '...'} - - {step.stepName} - {step.stepType && ( - [{step.stepType}] - )} - {step.finishedAt && step.startedAt && ( - - {step.finishedAt - step.startedAt}ms - - )} -
- {active && props.currentText && ( -
-                {props.currentText}
-              
- )} - {step.status === 'finished' && step.result !== undefined && ( -
- - result - -
-                  {JSON.stringify(step.result, null, 2)}
-                
-
+
+ ) +} + +function Header(props: { count: number }) { + return ( +
+ Pipeline Log + + {String(props.count).padStart(2, '0')} entries + +
+ ) +} + +function EmptyState() { + return ( +
+
+ nothing yet. +
+
awaiting first step
+
+ ) +} + +function Entry(props: { + ordinal: number + step: WorkflowStep + isActive: boolean + currentText?: string +}) { + const { ordinal, step, isActive, currentText } = props + const duration = + step.finishedAt && step.startedAt ? step.finishedAt - step.startedAt : null + + return ( +
  • +
    +
    + № {String(ordinal).padStart(2, '0')} +
    +
    + {isActive && ( +
    + )} +
    + +
    +
    +

    + {step.stepName} +

    + {step.stepType && ( + + {step.stepType.replace('-', ' · ')} + + )} + + {step.status === 'running' ? ( + <> + running + + + ) : step.status === 'failed' ? ( + 'failed' + ) : duration !== null ? ( + `${duration}ms` + ) : ( + 'finished' )} -
    - ) - })} + + + + {isActive && currentText && ( +
    +            {currentText}
    +            
    +          
    + )} + + {step.status === 'finished' && step.result !== undefined && ( + + )} + {step.status === 'failed' && step.result !== undefined && ( + + )} +
    +
  • + ) +} + +function ResultBlock(props: { result: unknown }) { + const text = typeof props.result === 'string' ? props.result : null + return ( +
    + + + ▸ + + result + +
    + {text !== null ? ( +

    + {text} +

    + ) : ( +
    +            {JSON.stringify(props.result, null, 2)}
    +          
    + )} +
    +
    + ) +} + +function FailureBlock(props: { result: unknown }) { + const result = props.result as { error?: { message?: string } } + const msg = result.error?.message ?? JSON.stringify(props.result) + return ( +
    +
    error
    +

    + {msg} +

    ) } diff --git a/examples/ts-react-chat/src/routes/orchestration.tsx b/examples/ts-react-chat/src/routes/orchestration.tsx index d8a68f920..6b958e7b3 100644 --- a/examples/ts-react-chat/src/routes/orchestration.tsx +++ b/examples/ts-react-chat/src/routes/orchestration.tsx @@ -8,62 +8,51 @@ export const Route = createFileRoute('/orchestration')({ component: OrchestrationPage, }) +interface OrchState { + phase?: string + spec?: { title: string; summary: string; files: Array } + result?: { + patches: Array<{ filename: string; patch: string }> + rationale: string + } + lastUserMessage?: string +} + function OrchestrationPage() { const [message, setMessage] = useState( 'add a /metrics endpoint to my Express app', ) - const orch = useOrchestration<{ userMessage: string }, unknown, unknown>({ + const orch = useOrchestration<{ userMessage: string }, unknown, OrchState>({ connection: fetchWorkflowEvents('/api/orchestration'), }) + const isRunning = orch.status === 'running' || orch.status === 'paused' + const phase = orch.state?.phase ?? 'idle' + return ( -
    -

    Feature Orchestrator

    +
    + -
    - setMessage(e.target.value)} - className="flex-1 border rounded px-2 py-1" - placeholder="Describe what you want" - disabled={orch.status === 'running' || orch.status === 'paused'} - /> - -
    + orch.start({ userMessage: message })} + onStop={() => orch.stop()} + disabled={isRunning} + canStop={isRunning} + /> {orch.pendingApproval && ( -
    -
    {orch.pendingApproval.title}
    - {orch.pendingApproval.description && ( -
    - {orch.pendingApproval.description} -
    - )} -
    - - -
    -
    + orch.approve(true)} + onDeny={() => orch.approve(false)} + /> )} -
    +
    + {orch.state?.spec && phase !== 'done' && ( + + )} + + {orch.state?.result && ( + + )} + {orch.error && ( -
    -
    Error
    -
    {orch.error.message}
    +
    +
    runtime error
    +
    {orch.error.message}
    )} + + +
    + ) +} + +function Masthead(props: { + status: string + phase: string + runId: string | null +}) { + return ( +
    +
    + + Volume I · Orchestrator No. 02 + + + {props.runId ? props.runId.slice(-12) : '—'} + +
    +
    +

    + Feature +
    + + Orchestrator + +

    + +
    + +
    + + + status — {props.status} + +
    +
    + +
    +
    + ) +} + +function PhaseBadge(props: { phase: string }) { + const labels: Array<{ key: string; label: string }> = [ + { key: 'scoping', label: 'Scoping' }, + { key: 'awaiting-approval', label: 'Awaiting' }, + { key: 'implementing', label: 'Implementing' }, + { key: 'review', label: 'Review' }, + { key: 'done', label: 'Done' }, + ] + return ( +
      + {labels.map((l, i) => { + const isCurrent = l.key === props.phase + return ( +
    1. + + {String(i + 1).padStart(2, '0')} · {l.label} + + {i < labels.length - 1 && /} +
    2. + ) + })} +
    + ) +} + +function StatusDot(props: { status: string }) { + const cls = + props.status === 'running' + ? 'bg-citron anim-citron-pulse' + : props.status === 'paused' + ? 'bg-citron' + : props.status === 'error' || props.status === 'aborted' + ? 'bg-rust' + : props.status === 'finished' + ? 'bg-moss' + : 'bg-taupe-deep' + return +} + +function Composer(props: { + message: string + onMessageChange: (m: string) => void + onRun: () => void + onStop: () => void + disabled: boolean + canStop: boolean +}) { + return ( +
    + +
    + props.onMessageChange(e.target.value)} + disabled={props.disabled} + className="flex-1 bg-transparent border-b-2 border-bone focus:border-citron outline-none px-1 py-3 text-2xl text-bone placeholder:text-taupe-deep transition-colors disabled:opacity-50" + style={{ + fontFamily: 'var(--font-display)', + fontVariationSettings: "'opsz' 36, 'SOFT' 50, 'WONK' 0", + }} + placeholder="Describe what you want built…" + /> + + {props.canStop && ( + + )} +
    ) } + +function ApprovalBand(props: { + title: string + description?: string + onApprove: () => void + onDeny: () => void +}) { + return ( +
    +
    +
    +
    +
    decision required
    +

    + {props.title} +

    + {props.description && ( +

    + {props.description} +

    + )} +
    +
    + + +
    +
    +
    +
    + ) +} + +function SpecReadout(props: { + spec: { title: string; summary: string; files: Array } +}) { + return ( +
    +
    + Spec Draft + + {props.spec.files.length} files + +
    +

    + {props.spec.title} +

    +

    + {props.spec.summary} +

    +
      + {props.spec.files.map((f) => ( +
    • + + {f} +
    • + ))} +
    +
    + ) +} + +function ImplementationReadout(props: { + result: { + patches: Array<{ filename: string; patch: string }> + rationale: string + } +}) { + return ( +
    +
    + Implementation + + {props.result.patches.length} patches + +
    +

    + “{props.result.rationale}” +

    +
    + {props.result.patches.map((p, i) => ( +
    +
    + + {String(i + 1).padStart(2, '0')} + + {p.filename} +
    +
    +              {p.patch}
    +            
    +
    + ))} +
    +
    + ) +} + +function Colophon() { + return ( +
    +
    + TanStack AI · Orchestration + Set in Fraunces & JetBrains Mono +
    +
    + ) +} diff --git a/examples/ts-react-chat/src/routes/workflow.tsx b/examples/ts-react-chat/src/routes/workflow.tsx index 35a49e871..1b0c37b66 100644 --- a/examples/ts-react-chat/src/routes/workflow.tsx +++ b/examples/ts-react-chat/src/routes/workflow.tsx @@ -8,66 +8,47 @@ export const Route = createFileRoute('/workflow')({ component: WorkflowPage, }) +type ArticleOutput = + | { ok: true; article: { title: string; paragraphs: Array } } + | { ok: false; reason: string } + function WorkflowPage() { const [topic, setTopic] = useState('the cultural history of pufferfish') - const wf = useWorkflow<{ topic: string }, unknown, unknown>({ + const wf = useWorkflow<{ topic: string }, ArticleOutput, unknown>({ connection: fetchWorkflowEvents('/api/workflow'), }) + const isRunning = wf.status === 'running' || wf.status === 'paused' + const finalStep = wf.steps.at(-1) + const finalResult = (wf.status === 'finished' ? finalStep?.result : null) as + | ArticleOutput + | null + | undefined + return ( -
    -

    Article Workflow

    +
    + -
    - setTopic(e.target.value)} - className="flex-1 border rounded px-2 py-1" - placeholder="Topic" - disabled={wf.status === 'running' || wf.status === 'paused'} - /> - - {(wf.status === 'running' || wf.status === 'paused') && ( - - )} -
    + wf.start({ topic })} + onStop={() => wf.stop()} + disabled={isRunning} + canStop={isRunning} + /> {wf.pendingApproval && ( -
    -
    {wf.pendingApproval.title}
    - {wf.pendingApproval.description && ( -
    {wf.pendingApproval.description}
    - )} -
    - - -
    -
    + wf.approve(true)} + onDeny={() => wf.approve(false)} + /> )} -
    +
    - {wf.status === 'finished' && wf.steps.length > 0 && ( -
    -
    Done
    -
    -            {JSON.stringify(wf.steps.at(-1)?.result, null, 2)}
    -          
    -
    + {finalResult && finalResult.ok && ( + + )} + + {finalResult && finalResult.ok === false && ( + )} {wf.error && ( -
    -
    Error
    -
    {wf.error.message}
    +
    +
    runtime error
    +
    {wf.error.message}
    )} + + +
    + ) +} + +function Masthead(props: { + status: string + runId: string | null +}) { + return ( +
    +
    + Volume I · Pipeline No. 01 + + {props.runId ? props.runId.slice(-12) : '—'} + +
    +
    +

    + Article
    + + Pipeline + +

    +
    + + + status — {props.status} + +
    +
    +
    + ) +} + +function StatusDot(props: { status: string }) { + const cls = + props.status === 'running' + ? 'bg-citron anim-citron-pulse' + : props.status === 'paused' + ? 'bg-citron' + : props.status === 'error' || props.status === 'aborted' + ? 'bg-rust' + : props.status === 'finished' + ? 'bg-moss' + : 'bg-taupe-deep' + return +} + +function Composer(props: { + topic: string + onTopicChange: (t: string) => void + onRun: () => void + onStop: () => void + disabled: boolean + canStop: boolean +}) { + return ( +
    + +
    + props.onTopicChange(e.target.value)} + disabled={props.disabled} + className="flex-1 bg-transparent border-b-2 border-bone focus:border-citron outline-none px-1 py-3 text-2xl text-bone placeholder:text-taupe-deep transition-colors disabled:opacity-50" + style={{ + fontFamily: 'var(--font-display)', + fontVariationSettings: "'opsz' 36, 'SOFT' 50, 'WONK' 0", + }} + placeholder="What should we write about?" + /> + + {props.canStop && ( + + )} +
    +
    + ) +} + +function ApprovalBand(props: { + title: string + description?: string + onApprove: () => void + onDeny: () => void +}) { + return ( +
    +
    +
    +
    +
    decision required
    +

    + {props.title} +

    + {props.description && ( +

    + {props.description} +

    + )} +
    +
    + + +
    +
    +
    ) } + +function PublishedArticle(props: { + article: { title: string; paragraphs: Array } +}) { + return ( +
    +
    + Published + + {new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
    +

    + {props.article.title} +

    +
    + {props.article.paragraphs.map((p, i) => ( +

    + {p} +

    + ))} +
    +
    + ) +} + +function RejectionNotice(props: { reason: string }) { + return ( +
    +
    +
    spiked
    +

    + “{props.reason}” +

    +
    +
    + ) +} + +function Colophon() { + return ( +
    +
    + TanStack AI · Orchestration + Set in Fraunces & JetBrains Mono +
    +
    + ) +} diff --git a/examples/ts-react-chat/src/styles.css b/examples/ts-react-chat/src/styles.css index 06f1bca4b..4578ad25b 100644 --- a/examples/ts-react-chat/src/styles.css +++ b/examples/ts-react-chat/src/styles.css @@ -1,15 +1,181 @@ @import 'tailwindcss'; +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,200..900,0..100,0..1;1,9..144,200..900,0..100,0..1&family=JetBrains+Mono:ital,wght@0,200..800;1,200..800&display=swap'); -body { - @apply m-0; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', - 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +@theme { + /* Editorial-brutalist palette: warm ink ground, bone cream type, single citron accent. */ + --color-ink: #14110e; + --color-ink-soft: #1d1916; + --color-ink-line: #2a2520; + --color-cream: #f4ebe0; + --color-cream-soft: #ece1d2; + --color-bone: #e8dfd1; + --color-taupe: #93887a; + --color-taupe-deep: #6a5f53; + --color-citron: #ffce00; + --color-citron-deep: #d8ad00; + --color-rust: #c84b1c; + --color-moss: #a8b86b; + + --font-display: 'Fraunces', 'Iowan Old Style', Georgia, serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Consolas, monospace; +} + +@layer base { + html, body { + background-color: var(--color-ink); + color: var(--color-bone); + font-family: var(--font-display); + font-optical-sizing: auto; + } + + body { + margin: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-image: + radial-gradient(ellipse 1200px 600px at 50% -10%, rgba(255, 206, 0, 0.04), transparent 60%), + radial-gradient(ellipse 800px 400px at 100% 100%, rgba(200, 75, 28, 0.03), transparent 60%); + min-height: 100dvh; + } + + /* Subtle paper grain via SVG noise filter — sits under content. */ + body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.06; + mix-blend-mode: overlay; + z-index: 0; + background-image: url("data:image/svg+xml;utf8,"); + } + + ::selection { + background-color: var(--color-citron); + color: var(--color-ink); + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: var(--color-ink-line); + border-radius: 0; + } + ::-webkit-scrollbar-thumb:hover { + background: var(--color-taupe-deep); + } +} + +/* ---------- Fraunces utility shorthands ---------- */ +.font-display-tight { + font-family: var(--font-display); + font-variation-settings: 'opsz' 144, 'SOFT' 30, 'WONK' 1; + letter-spacing: -0.025em; +} + +.font-display-soft { + font-family: var(--font-display); + font-variation-settings: 'opsz' 24, 'SOFT' 100, 'WONK' 0; +} + +.font-display-italic { + font-family: var(--font-display); + font-style: italic; + font-variation-settings: 'opsz' 144, 'SOFT' 80, 'WONK' 1; +} + +/* ---------- Decorative rules ---------- */ +.rule-thick { + border: 0; + height: 4px; + background-color: var(--color-bone); +} +.rule-double { + border: 0; + height: 9px; + border-top: 1px solid var(--color-bone); + border-bottom: 1px solid var(--color-bone); +} +.rule-hair { + border: 0; + height: 1px; + background-color: var(--color-ink-line); +} + +/* ---------- Tape / hazard band ---------- */ +.tape-citron { + background-image: repeating-linear-gradient( + -45deg, + var(--color-citron) 0 14px, + var(--color-ink) 14px 22px + ); +} + +/* ---------- Mono utilities ---------- */ +.label-mono { + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 500; +} +.tabular { + font-variant-numeric: tabular-nums; +} + +/* ---------- Animations ---------- */ +@keyframes log-fade-in { + from { + opacity: 0; + transform: translateY(6px); + filter: blur(2px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +} +.anim-log-in { + animation: log-fade-in 360ms cubic-bezier(0.2, 0.6, 0.2, 1) both; +} + +@keyframes citron-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} +.anim-citron-pulse { + animation: citron-pulse 1.6s ease-in-out infinite; +} + +@keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} +.anim-blink { + animation: blink 1s steps(1, end) infinite; +} + +@keyframes slip-in { + from { + opacity: 0; + transform: translateX(-12px) rotate(-0.4deg); + } + to { + opacity: 1; + transform: translateX(0) rotate(-0.4deg); + } +} +.anim-slip-in { + animation: slip-in 420ms cubic-bezier(0.2, 0.6, 0.2, 1) both; } -code { - font-family: - source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +@keyframes type-in { + from { width: 0; } + to { width: 100%; } } From 85224f37c51bb1b6361719fe7b1de5fc04eb9b19 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:51:25 +0200 Subject: [PATCH 42/50] feat(ai-orchestration,ai-client): stream workflow output through RUN_FINISHED event Include the workflow's typed return value in the RUN_FINISHED chunk and capture it in WorkflowClientState.output so the client can render the published article from wf.output instead of the last step result. --- packages/typescript/ai-client/src/workflow-client.ts | 9 ++++++--- .../ai-orchestration/src/engine/emit-events.ts | 2 ++ .../ai-orchestration/src/engine/run-workflow.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/typescript/ai-client/src/workflow-client.ts b/packages/typescript/ai-client/src/workflow-client.ts index c00089e50..ba55925a0 100644 --- a/packages/typescript/ai-client/src/workflow-client.ts +++ b/packages/typescript/ai-client/src/workflow-client.ts @@ -108,7 +108,7 @@ export class WorkflowClient< } } - async approve(approved: boolean): Promise { + async approve(approved: boolean, feedback?: string): Promise { if (!this.clientState.pendingApproval || !this.clientState.runId) { throw new Error('No pending approval') } @@ -119,7 +119,7 @@ export class WorkflowClient< status: 'running', }) const workflowStream = this.openStream({ - approval: { approvalId, approved }, + approval: { approvalId, approved, feedback }, runId, }) await this.consumeStream(workflowStream) @@ -186,7 +186,10 @@ export class WorkflowClient< break } case 'RUN_FINISHED': - this.setState({ status: 'finished' }) + this.setState({ + status: 'finished', + output: chunk.output as TOutput, + } as Partial>) break case 'RUN_STARTED': this.setState({ diff --git a/packages/typescript/ai-orchestration/src/engine/emit-events.ts b/packages/typescript/ai-orchestration/src/engine/emit-events.ts index 213b54d2a..ef4617630 100644 --- a/packages/typescript/ai-orchestration/src/engine/emit-events.ts +++ b/packages/typescript/ai-orchestration/src/engine/emit-events.ts @@ -21,12 +21,14 @@ export function runStartedEvent(args: { export function runFinishedEvent(args: { runId: string threadId?: string + output?: unknown }): StreamChunk { return { type: 'RUN_FINISHED', timestamp: Date.now(), runId: args.runId, threadId: args.threadId ?? args.runId, + output: args.output, } as StreamChunk } diff --git a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts index 4650cae9c..f8eb82f7a 100644 --- a/packages/typescript/ai-orchestration/src/engine/run-workflow.ts +++ b/packages/typescript/ai-orchestration/src/engine/run-workflow.ts @@ -391,7 +391,7 @@ async function* driveLoop( updatedAt: Date.now(), } await runStore.set(runId, live.runState) - yield runFinishedEvent({ runId, threadId }) + yield runFinishedEvent({ runId, threadId, output: finalOutput }) await runStore.delete(runId, 'finished') } catch (err) { if (abortController.signal.aborted) { From c0202efb80f0e0cf14817d63311a5c3b4747f29f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:51:34 +0200 Subject: [PATCH 43/50] feat(ai-orchestration,ai-react): support free-text feedback on approval response Extend ApprovalResult with optional feedback field, thread it through the approve() method on WorkflowClient and the useWorkflow hook so the UI can request targeted editor revisions instead of binary approve/deny. --- packages/typescript/ai-orchestration/src/types.ts | 2 ++ packages/typescript/ai-react/src/use-workflow.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/typescript/ai-orchestration/src/types.ts b/packages/typescript/ai-orchestration/src/types.ts index f5959b74d..81b461387 100644 --- a/packages/typescript/ai-orchestration/src/types.ts +++ b/packages/typescript/ai-orchestration/src/types.ts @@ -141,6 +141,8 @@ export type StepGenerator = Generator export interface ApprovalResult { approved: boolean approvalId: string + /** Optional free-text feedback. Set when the user denies and asks for revisions. */ + feedback?: string } // ========================================== diff --git a/packages/typescript/ai-react/src/use-workflow.ts b/packages/typescript/ai-react/src/use-workflow.ts index 8102e6c84..9999eed71 100644 --- a/packages/typescript/ai-react/src/use-workflow.ts +++ b/packages/typescript/ai-react/src/use-workflow.ts @@ -21,7 +21,7 @@ export interface UseWorkflowReturn< TOutput = unknown, TState = unknown, > extends WorkflowClientState { - approve: (approved: boolean) => Promise + approve: (approved: boolean, feedback?: string) => Promise start: (input: TInput) => Promise stop: () => void } @@ -63,7 +63,7 @@ export function useWorkflow< }, [client]) const approve = useCallback( - (approved: boolean) => client.approve(approved), + (approved: boolean, feedback?: string) => client.approve(approved, feedback), [client], ) const start = useCallback((input: TInput) => client.start(input), [client]) From 6db5d961295ea7f5cf9cd1e2c445df9c244226a5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:51:45 +0200 Subject: [PATCH 44/50] feat(ts-react-chat): article revision loop with editor feedback and 3-way approval UI Restructure the article workflow to edit-first-then-approve: after legal and skeptic reviews the editor runs immediately, then a revision loop (max 4 rounds) asks Publish/Revise/Discard. Revise re-runs the editor with the textarea feedback. The UI gains a textarea and a third button; the published article now renders from wf.output (RUN_FINISHED output). --- .../src/lib/workflows/article-workflow.ts | 43 ++++++---- examples/ts-react-chat/src/routeTree.gen.ts | 84 +++++++++++++++++++ .../ts-react-chat/src/routes/workflow.tsx | 46 ++++++---- 3 files changed, 142 insertions(+), 31 deletions(-) diff --git a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts index ca0fa4958..458d6a87c 100644 --- a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts +++ b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts @@ -35,7 +35,7 @@ const ArticleOutput = z.union([ const ArticleState = z.object({ phase: z - .enum(['drafting', 'reviewing', 'awaiting-approval', 'editing', 'done']) + .enum(['drafting', 'reviewing', 'editing', 'awaiting-approval', 'revising', 'done']) .default('drafting'), draft: Draft.optional(), legalReview: Review.optional(), @@ -126,34 +126,43 @@ export const articleWorkflow = defineWorkflow({ const legal = yield* agents.legal({ draft }) state.legalReview = legal if (legal.verdict === 'block') { - state.phase = 'done' return fail(`legal: ${legal.findings.join('; ')}`) } const skeptic = yield* agents.skeptic({ draft }) state.skepticReview = skeptic if (skeptic.verdict === 'block') { - state.phase = 'done' return fail(`skeptic: ${skeptic.findings.join('; ')}`) } - state.phase = 'awaiting-approval' - const decision = yield* approve({ - title: 'Publish this draft?', - description: `"${draft.title}" passed both reviews.`, - }) - if (!decision.approved) { - state.phase = 'done' - return fail('user denied') - } - state.phase = 'editing' - const final = yield* agents.editor({ + let current = yield* agents.editor({ draft, notes: [...legal.findings, ...skeptic.findings], }) - state.draft = final - state.phase = 'done' - return succeed({ article: final }) + state.draft = current + + for (let round = 0; round < 4; round++) { + state.phase = 'awaiting-approval' + const decision = yield* approve({ + title: round === 0 ? 'Publish this article?' : 'Publish the revision?', + description: current.title, + }) + if (decision.approved) { + state.phase = 'done' + return succeed({ article: current }) + } + if (!decision.feedback || !decision.feedback.trim()) { + state.phase = 'done' + return fail('user denied') + } + state.phase = 'revising' + current = yield* agents.editor({ + draft: current, + notes: [decision.feedback], + }) + state.draft = current + } + return fail('too many revision rounds') }, }) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index f9b2ac825..726cc03c9 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -9,7 +9,9 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WorkflowRouteImport } from './routes/workflow' import { Route as RealtimeRouteImport } from './routes/realtime' +import { Route as OrchestrationRouteImport } from './routes/orchestration' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' @@ -19,10 +21,12 @@ import { Route as GenerationsStructuredOutputRouteImport } from './routes/genera import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' +import { Route as ApiWorkflowRouteImport } from './routes/api.workflow' import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe' import { Route as ApiTanchatRouteImport } from './routes/api.tanchat' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured-output' +import { Route as ApiOrchestrationRouteImport } from './routes/api.orchestration' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' @@ -31,11 +35,21 @@ import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.spe import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +const WorkflowRoute = WorkflowRouteImport.update({ + id: '/workflow', + path: '/workflow', + getParentRoute: () => rootRouteImport, +} as any) const RealtimeRoute = RealtimeRouteImport.update({ id: '/realtime', path: '/realtime', getParentRoute: () => rootRouteImport, } as any) +const OrchestrationRoute = OrchestrationRouteImport.update({ + id: '/orchestration', + path: '/orchestration', + getParentRoute: () => rootRouteImport, +} as any) const ImageGenRoute = ImageGenRouteImport.update({ id: '/image-gen', path: '/image-gen', @@ -83,6 +97,11 @@ const GenerationsAudioRoute = GenerationsAudioRouteImport.update({ path: '/generations/audio', getParentRoute: () => rootRouteImport, } as any) +const ApiWorkflowRoute = ApiWorkflowRouteImport.update({ + id: '/api/workflow', + path: '/api/workflow', + getParentRoute: () => rootRouteImport, +} as any) const ApiTranscribeRoute = ApiTranscribeRouteImport.update({ id: '/api/transcribe', path: '/api/transcribe', @@ -103,6 +122,11 @@ const ApiStructuredOutputRoute = ApiStructuredOutputRouteImport.update({ path: '/api/structured-output', getParentRoute: () => rootRouteImport, } as any) +const ApiOrchestrationRoute = ApiOrchestrationRouteImport.update({ + id: '/api/orchestration', + path: '/api/orchestration', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageGenRoute = ApiImageGenRouteImport.update({ id: '/api/image-gen', path: '/api/image-gen', @@ -142,12 +166,16 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/orchestration': typeof OrchestrationRoute '/realtime': typeof RealtimeRoute + '/workflow': typeof WorkflowRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/orchestration': typeof ApiOrchestrationRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/api/workflow': typeof ApiWorkflowRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -165,12 +193,16 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/orchestration': typeof OrchestrationRoute '/realtime': typeof RealtimeRoute + '/workflow': typeof WorkflowRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/orchestration': typeof ApiOrchestrationRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/api/workflow': typeof ApiWorkflowRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -189,12 +221,16 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/orchestration': typeof OrchestrationRoute '/realtime': typeof RealtimeRoute + '/workflow': typeof WorkflowRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/orchestration': typeof ApiOrchestrationRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/api/workflow': typeof ApiWorkflowRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -214,12 +250,16 @@ export interface FileRouteTypes { fullPaths: | '/' | '/image-gen' + | '/orchestration' | '/realtime' + | '/workflow' | '/api/image-gen' + | '/api/orchestration' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/api/workflow' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -237,12 +277,16 @@ export interface FileRouteTypes { to: | '/' | '/image-gen' + | '/orchestration' | '/realtime' + | '/workflow' | '/api/image-gen' + | '/api/orchestration' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/api/workflow' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -260,12 +304,16 @@ export interface FileRouteTypes { | '__root__' | '/' | '/image-gen' + | '/orchestration' | '/realtime' + | '/workflow' | '/api/image-gen' + | '/api/orchestration' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/api/workflow' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -284,12 +332,16 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImageGenRoute: typeof ImageGenRoute + OrchestrationRoute: typeof OrchestrationRoute RealtimeRoute: typeof RealtimeRoute + WorkflowRoute: typeof WorkflowRoute ApiImageGenRoute: typeof ApiImageGenRoute + ApiOrchestrationRoute: typeof ApiOrchestrationRoute ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiTanchatRoute: typeof ApiTanchatRoute ApiTranscribeRoute: typeof ApiTranscribeRoute + ApiWorkflowRoute: typeof ApiWorkflowRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute @@ -307,6 +359,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/workflow': { + id: '/workflow' + path: '/workflow' + fullPath: '/workflow' + preLoaderRoute: typeof WorkflowRouteImport + parentRoute: typeof rootRouteImport + } '/realtime': { id: '/realtime' path: '/realtime' @@ -314,6 +373,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RealtimeRouteImport parentRoute: typeof rootRouteImport } + '/orchestration': { + id: '/orchestration' + path: '/orchestration' + fullPath: '/orchestration' + preLoaderRoute: typeof OrchestrationRouteImport + parentRoute: typeof rootRouteImport + } '/image-gen': { id: '/image-gen' path: '/image-gen' @@ -377,6 +443,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsAudioRouteImport parentRoute: typeof rootRouteImport } + '/api/workflow': { + id: '/api/workflow' + path: '/api/workflow' + fullPath: '/api/workflow' + preLoaderRoute: typeof ApiWorkflowRouteImport + parentRoute: typeof rootRouteImport + } '/api/transcribe': { id: '/api/transcribe' path: '/api/transcribe' @@ -405,6 +478,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStructuredOutputRouteImport parentRoute: typeof rootRouteImport } + '/api/orchestration': { + id: '/api/orchestration' + path: '/api/orchestration' + fullPath: '/api/orchestration' + preLoaderRoute: typeof ApiOrchestrationRouteImport + parentRoute: typeof rootRouteImport + } '/api/image-gen': { id: '/api/image-gen' path: '/api/image-gen' @@ -460,12 +540,16 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImageGenRoute: ImageGenRoute, + OrchestrationRoute: OrchestrationRoute, RealtimeRoute: RealtimeRoute, + WorkflowRoute: WorkflowRoute, ApiImageGenRoute: ApiImageGenRoute, + ApiOrchestrationRoute: ApiOrchestrationRoute, ApiStructuredOutputRoute: ApiStructuredOutputRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiTanchatRoute: ApiTanchatRoute, ApiTranscribeRoute: ApiTranscribeRoute, + ApiWorkflowRoute: ApiWorkflowRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, diff --git a/examples/ts-react-chat/src/routes/workflow.tsx b/examples/ts-react-chat/src/routes/workflow.tsx index 1b0c37b66..30b65a8b3 100644 --- a/examples/ts-react-chat/src/routes/workflow.tsx +++ b/examples/ts-react-chat/src/routes/workflow.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useState } from 'react' +import { useRef, useState } from 'react' import { fetchWorkflowEvents, useWorkflow } from '@tanstack/ai-react' import { StateInspector } from '@/components/StateInspector' import { WorkflowTimeline } from '@/components/WorkflowTimeline' @@ -20,11 +20,9 @@ function WorkflowPage() { }) const isRunning = wf.status === 'running' || wf.status === 'paused' - const finalStep = wf.steps.at(-1) - const finalResult = (wf.status === 'finished' ? finalStep?.result : null) as + const finalResult = (wf.status === 'finished' ? wf.output : null) as | ArticleOutput | null - | undefined return (
    @@ -43,8 +41,9 @@ function WorkflowPage() { wf.approve(true)} - onDeny={() => wf.approve(false)} + onPublish={() => wf.approve(true)} + onRevise={(feedback) => wf.approve(false, feedback)} + onDiscard={() => wf.approve(false)} /> )} @@ -176,13 +175,17 @@ function Composer(props: { function ApprovalBand(props: { title: string description?: string - onApprove: () => void - onDeny: () => void + onPublish: () => void + onRevise: (feedback: string) => void + onDiscard: () => void }) { + const textareaRef = useRef(null) + const [feedback, setFeedback] = useState('') + return (
    -
    +
    decision required

    )} +