UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

328 lines (254 loc) • 9.67 kB
--- title: Workflow Utilities description: Run HarnessAgent turns as durable Workflow DevKit workflows. --- # Workflow Utilities `@ai-sdk/workflow-harness` provides helpers for running `HarnessAgent` turns inside a [workflow](https://vercel.com/docs/workflow). The package provides a serializable state machine and a slice runner that you call from your own `'use workflow'` and `'use step'` functions. The core harness and workflow files are framework-independent. The HTTP handler and Next.js configuration shown below are examples of how to expose the workflow from one app framework; adapt those parts to your runtime and Workflow SDK integration. ## Installation <InstallPackages packages="@ai-sdk/workflow-harness workflow" /> Install the core harness package, a harness adapter, and a sandbox provider as shown in [HarnessAgent](/docs/ai-sdk-harnesses/harness-agent). ## Using a Durable Harness Agent The agent can be configured in the usual way. ```ts filename='harness-workflow/agent.ts' import { HarnessAgent } from '@ai-sdk/harness/agent'; import { claudeCode } from '@ai-sdk/harness-claude-code'; import { createVercelSandbox } from '@ai-sdk/sandbox-vercel'; export const agent = new HarnessAgent({ harness: claudeCode, sandbox: createVercelSandbox({ runtime: 'node24', ports: [4000], }), instructions: 'You are a helpful coding assistant.', }); ``` With the agent definition file in place, start with the two workflow-specific pieces: a slice step that runs one time-boxed part of the harness turn, and a workflow that calls that step until the turn finishes. ```ts filename='harness-workflow/run-slice-step.ts' import { runHarnessAgentSlice, type HarnessWorkflowState, } from '@ai-sdk/workflow-harness'; export async function runSlice( state: HarnessWorkflowState, ): Promise<HarnessWorkflowState> { 'use step'; const { agent } = await import('./agent'); return runHarnessAgentSlice({ agent, state, }); } ``` ```ts filename='harness-workflow/workflow.ts' import { runSlice } from './run-slice-step'; import { createHarnessWorkflowState, finalizeHarnessWorkflow, type HarnessWorkflowInput, } from '@ai-sdk/workflow-harness'; export async function codingWorkflow( input: Pick<HarnessWorkflowInput, 'prompt' | 'sessionId'>, ) { 'use workflow'; let state = createHarnessWorkflowState(input); while (state.status === 'running' || state.status === 'timed_out') { state = await runSlice(state); } return finalizeHarnessWorkflow(state); } ``` ### Starting the Workflow Start the workflow from server-side code. You can return `run.readable` from an HTTP endpoint, consume it directly, or expose it through your framework's streaming response primitive. ```ts filename='server/start-coding-workflow.ts' import { codingWorkflow } from '../harness-workflow/workflow'; import { start } from 'workflow/api'; export async function startCodingWorkflow({ prompt, sessionId, }: { prompt: string; sessionId: string; }) { const run = await start(codingWorkflow, [{ prompt, sessionId }]); return run.readable; } ``` This is the core workflow/harness integration. Add resume persistence when you need a multi-turn chat to reattach to the same native harness session across workflow runs. ### HTTP Route Example This example uses a Next.js `Request`/`Response` handler and AI SDK UI message streams. For other frameworks, the workflow related code is similar: derive the newest user message, call `start(codingWorkflow, [...])`, and return the run's readable stream. ```ts filename='app/api/harness-workflow/route.ts' import { codingWorkflow } from '../../../harness-workflow/workflow'; import { convertToModelMessages, createUIMessageStreamResponse, type UIMessage, type UIMessageChunk, } from 'ai'; import { start } from 'workflow/api'; function latestUserMessage( messages: Awaited<ReturnType<typeof convertToModelMessages>>, ) { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message.role === 'user') return message; } } export async function POST(request: Request) { const body: { id?: string; messages: UIMessage[]; } = await request.json(); if (!body.id) { return new Response('Missing chat ID', { status: 400 }); } const prompt = latestUserMessage(await convertToModelMessages(body.messages)); if (!prompt) { return new Response('No user message to run', { status: 400 }); } const run = await start(codingWorkflow, [ { prompt, sessionId: body.id, }, ]); return createUIMessageStreamResponse({ stream: run.readable as ReadableStream<UIMessageChunk>, }); } ``` If you use Next.js, wrap your config with `withWorkflow()` so Workflow SDK transforms `'use workflow'` and `'use step'` modules. Other frameworks have their own Workflow SDK setup. ```js filename='next.config.js' const { withWorkflow } = require('workflow/next'); const nextConfig = {}; module.exports = withWorkflow(nextConfig, {}); ``` ## Resume Persistence Persist only the opaque `resumeFrom` state returned after a finished turn. The example below uses Workflow steps because filesystem access must stay out of the workflow function itself. Use durable storage instead of local files in production. ```ts filename='harness-workflow/resume-store.ts' import type { HarnessV1ResumeSessionState } from '@ai-sdk/harness'; import { safeParseJSON } from '@ai-sdk/provider-utils'; const RESUME_DIR = '.harness-sessions'; function fileName(sessionId: string): string { return `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`; } export async function loadResumeStep( sessionId: string, ): Promise<HarnessV1ResumeSessionState | undefined> { 'use step'; const { readFile } = await import('node:fs/promises'); const { join } = await import('node:path'); let text: string; try { text = await readFile( join(process.cwd(), RESUME_DIR, fileName(sessionId)), 'utf8', ); } catch { return undefined; } const parsed = await safeParseJSON({ text }); return parsed.success ? (parsed.value as unknown as HarnessV1ResumeSessionState) : undefined; } export async function persistResumeStep({ sessionId, resumeState, }: { sessionId: string; resumeState: HarnessV1ResumeSessionState | undefined; }): Promise<void> { 'use step'; if (!resumeState) return; const { mkdir, writeFile } = await import('node:fs/promises'); const { join } = await import('node:path'); const dir = join(process.cwd(), RESUME_DIR); await mkdir(dir, { recursive: true }); await writeFile(join(dir, fileName(sessionId)), JSON.stringify(resumeState)); } ``` Load the resume state before the first slice and persist the new one after the turn finishes: ```ts filename='harness-workflow/workflow.ts' import { loadResumeStep, persistResumeStep } from './resume-store'; import { runSlice } from './run-slice-step'; import { createHarnessWorkflowState, finalizeHarnessWorkflow, type HarnessWorkflowInput, } from '@ai-sdk/workflow-harness'; export async function codingWorkflow( input: Pick<HarnessWorkflowInput, 'prompt' | 'sessionId'>, ) { 'use workflow'; const resumeFrom = await loadResumeStep(input.sessionId); let state = createHarnessWorkflowState({ ...input, resumeFrom }); while (state.status === 'running' || state.status === 'timed_out') { state = await runSlice(state); } await persistResumeStep({ sessionId: state.sessionId, resumeState: state.resumeFrom, }); return finalizeHarnessWorkflow(state); } ``` The harness `sessionId` gives the sandbox a stable identity across workflow runs and lets the workflow load the previous turn's resume state before sending the next user message. A harness session owns its native conversation history, so the route sends only the newest user message. Do not replay the full UI message history into the harness. ## How It Works Each user turn is represented by a `HarnessWorkflowState`: - `createHarnessWorkflowState()` creates the initial state for the turn. - `runHarnessAgentSlice()` streams one time-boxed slice of the turn. - If the slice times out, the harness turn is suspended non-destructively and the returned state contains `continueFrom` for the next slice. - If the turn finishes, the helper closes the workflow output stream and returns `resumeFrom` for the next user turn. - `finalizeHarnessWorkflow()` returns the final result or throws when the workflow failed. The workflow output stream receives AI SDK UI message chunks, so a route can return `run.readable` through `createUIMessageStreamResponse()`. ## File Boundaries Keep the `workflow` entrypoints separate from the agent definition: - `agent.ts` defines the `HarnessAgent`. - `run-slice-step.ts` is a `'use step'` module and imports the agent dynamically inside the step body. - `workflow.ts` contains the `'use workflow'` function and imports only workflow-safe helpers and step modules. - your route or server entrypoint starts the workflow. It can import `ai` helpers such as `convertToModelMessages()` and `createUIMessageStreamResponse()`. Do not define the workflow function in the same module as the route handler, server entrypoint, or agent. The Workflow DevKit compiles the module graph reachable from a `'use workflow'` directive. Keeping the graph small prevents Node-heavy agent, sandbox, and framework dependencies from being pulled into the workflow bundle. ## Related - [HarnessAgent](/docs/ai-sdk-harnesses/harness-agent) - [Harness adapters](/docs/ai-sdk-harnesses/harness-adapters) - [UI](/docs/ai-sdk-harnesses/ui)