UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

221 lines (209 loc) • 8 kB
import { EventType, defineAgent } from '@tanstack/ai' import { defineCommand, definePlugin } from '@tanstack/ai-harness' import { withSandbox } from './middleware' import type { AnyChatMiddleware, AnyTextAdapter, JSONSchema, StreamChunk, } from '@tanstack/ai' import type { SandboxMiddlewareOptions } from './middleware' import type { SandboxDefinition } from './sandbox' export { sandboxWorkspaceBackend } from './workspace-backend' export interface CodingAgentConfig { /** A coding-agent adapter: `claudeCodeText`, `codexText`, `grokBuildText`, `acpCompatibleText`, and more. */ adapter: AnyTextAdapter /** When the lead model should pick this agent. */ description: string /** Options for every call, for example Claude Code's `permissionMode`. */ modelOptions?: Record<string, unknown> /** * Options added when the harness `mode` setting is `plan`. Default: Claude * Code `{ permissionMode: 'plan' }` and Codex `{ sandboxMode: 'read-only' }`. */ planModelOptions?: Record<string, unknown> } export interface CodingAgentsOptions { /** Where the agents work. One sandbox per harness thread (or per agent). */ sandbox: SandboxDefinition /** The agents, keyed by the tool name the lead model sees. */ agents: Record<string, CodingAgentConfig> /** * `shared` (default): every agent of a thread works in one sandbox, one * agent at a time. `per-agent`: each agent gets its own sandbox. */ workspace?: 'shared' | 'per-agent' /** Passed to `withSandbox`. */ sandboxOptions?: SandboxMiddlewareOptions } const PLAN_DEFAULTS: Record<string, Record<string, unknown>> = { 'claude-code': { permissionMode: 'plan' }, codex: { sandboxMode: 'read-only' }, } const TASK_SCHEMA: JSONSchema = { type: 'object', properties: { task: { type: 'string', description: 'The whole task: what to change, where, and how to check it. The agent sees only this text and the workspace.', }, }, required: ['task'], } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null } /** * Run `open` while holding the lock for `key`. The lock is taken when the * stream is first read, so a stream nobody reads never holds it. */ async function* serialized( tails: Map<string, Promise<void>>, key: string, open: () => AsyncIterable<StreamChunk>, ): AsyncGenerator<StreamChunk> { const previous = tails.get(key) ?? Promise.resolve() let release = () => {} const current = new Promise<void>((resolve) => (release = resolve)) const tail = previous.then(() => current) tails.set(key, tail) await previous try { yield* open() } finally { release() if (tails.get(key) === tail) tails.delete(key) } } /** * A harness plugin that lets the lead model hand coding work to Claude Code, * Codex, Grok Build, or any ACP agent: * * - each agent runs in `sandbox`, so nobody wires `withSandbox` by hand; * - each agent keeps its own coding session per thread, also after a restart * (`/fresh [agent]` starts over); * - the harness `plan` mode starts the agents read-only. * * @example * ```ts * codingAgents({ * sandbox: defineSandbox({ id: 'code', provider: localProcessSandbox() }), * agents: { * claude_code: { adapter: claudeCodeText('claude-opus-4-8'), description: 'Large refactors' }, * codex: { adapter: codexText('gpt-5.5'), description: 'Quick fixes and tests' }, * }, * }) * ``` */ export function codingAgents(options: CodingAgentsOptions) { const shared = (options.workspace ?? 'shared') === 'shared' const sandboxOptions = options.sandboxOptions ?? {} // One agent at a time for each thread. Only the host that owns the log of // a thread runs its agents, and a host that loses the log stops its runs. // So a lock in this process holds across hosts too. const tails = new Map<string, Promise<void>>() const names = Object.keys(options.agents) return definePlugin({ name: 'tanstack/coding-agents', setup: (ctx) => { const state = ctx.state<{ sessions: Record<string, string> }>({ sessions: {}, }) // Saves the session id the adapter reports (`claude-code.session-id`, ...). const capture = (agent: string): AnyChatMiddleware => ({ name: 'tanstack/coding-agents:session', onChunk: async (_run, chunk) => { if (chunk.type !== EventType.CUSTOM) return if (!chunk.name.endsWith('.session-id')) return const value: unknown = chunk.value if (!isRecord(value) || typeof value.sessionId !== 'string') return const sessionId = value.sessionId await state.update((current) => ({ sessions: { ...current.sessions, [agent]: sessionId }, })) }, }) const subagents = Object.entries(options.agents).map(([name, config]) => defineAgent({ name, description: config.description, inputSchema: TASK_SCHEMA, run: async (run) => { const input: unknown = run.input const task = isRecord(input) && typeof input.task === 'string' ? input.task : '' const plan = ctx.config.get('mode') === 'plan' ? (config.planModelOptions ?? PLAN_DEFAULTS[config.adapter.name]) : {} // Fail closed: an agent with no read-only options must not edit. if (plan === undefined) { throw new Error( `${name} cannot run in plan mode: it has no planModelOptions.`, ) } // The harness thread, not `run.threadId`: a child's own thread is // `<thread>:<agent>` by default. const thread = ctx.session.threadId const threadId = shared ? thread : `${thread}:${name}` // Inside the lock, so a queued call resumes the session of the // call before it. async function* open(): AsyncGenerator<StreamChunk> { const sessionId = (await state.get()).sessions[name] const modelOptions = { ...config.modelOptions, ...plan, ...(sessionId ? { sessionId } : {}), } yield* run.chat({ adapter: config.adapter, messages: [{ role: 'user', content: task }], threadId, ...(Object.keys(modelOptions).length > 0 ? { modelOptions } : {}), middleware: [ withSandbox(options.sandbox, sandboxOptions), capture(name), ], }) as AsyncIterable<StreamChunk> } // One call at a time per sandbox, also for the same agent in // `per-agent` mode. return serialized(tails, threadId, open) }, }), ) return { subagents, commands: { fresh: defineCommand({ description: `Start new coding-agent sessions (all, or one of: ${names.join(', ')})`, run: async (input: unknown) => { const name = typeof input === 'string' ? input.trim() : '' if (name && !names.includes(name)) { return `Unknown coding agent "${name}". Agents: ${names.join(', ')}.` } await state.update((current) => ({ sessions: name ? Object.fromEntries( Object.entries(current.sessions).filter( ([agent]) => agent !== name, ), ) : {}, })) return name ? `${name} starts a new session next time.` : 'Every coding agent starts a new session next time.' }, }), }, } }, }) }