diff --git a/.changeset/ai-autopilot-runner.md b/.changeset/ai-autopilot-runner.md new file mode 100644 index 0000000..06bbe3f --- /dev/null +++ b/.changeset/ai-autopilot-runner.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add the runner seam: a pluggable `Runner` execution environment (workspace filesystem + shell + optional preview URL), shaped after Flue's `sandbox` contract so WebContainer, Docker, and Flue sandboxes drop in behind one interface. Ships the interface plus `FakeRunner` (the runner analog of `ai-sdk`'s `AiFake`) for infra-free testing, and `runnerTools(session)` to expose a booted session to an agent as sandbox tools (`read_file`, `write_file`, `list_files`, `exec`, `preview`). Real adapters (FlueRunner, WebContainer, Docker) land separately. Interface-first slice of the runner child (#99) of the ai-autopilot epic (#97). diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index 81ea8ae..975a23b 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -70,6 +70,42 @@ Define your own with `definePersona({ name, role, systemPrompt, skills?, tools? or materialize a single persona into an agent with `personaAgent(persona)`. Because a persona is data, it can be inspected and listed without building an agent first. +## Runner — the pluggable execution seam + +Autopilot builds and runs an app somewhere. A **`Runner`** boots an isolated +workspace (a virtual filesystem + a shell + an optional preview URL) and is +shaped after Flue's `sandbox` contract, so real sandboxes drop in behind one +interface: **WebContainer** (instant in-browser Vike preview), a **Docker** +sandbox on our servers, or a **Flue** sandbox (in-memory / edge / container). We +sit on those harnesses rather than competing with them. + +This package ships the interface plus a **`FakeRunner`** (the runner analog of +`ai-sdk`'s `AiFake`) so autopilot can be driven and tested without any sandbox +infra. Real adapters land as separate packages. + +```ts +import { FakeRunner, runnerTools } from '@gemstack/ai-autopilot' +import { personaAgent, vikePageBuilder } from '@gemstack/ai-autopilot' + +const runner = new FakeRunner() +const session = await runner.boot({ files: { 'pages/+config.js': '…' } }) + +// Give a persona hands inside the sandbox: read/write files, exec, preview. +const agent = personaAgent(vikePageBuilder, { model: 'anthropic/claude-sonnet-4-5' }) +const withTools = agent // compose runnerTools(session) into its tools() + +await session.exec('pnpm build') +const { url } = (await session.preview?.({ port: 5173 })) ?? {} +``` + +`runnerTools(session)` exposes the session to an agent as `ai-sdk` tools +(`read_file`, `write_file`, `list_files`, `exec`, and — only when the session +supports it — `preview`). Toggle `write` / `exec` for a read-only surface, or set +a `prefix` to avoid name collisions. + +To implement a real runner, satisfy the `Runner` interface: `boot()` returns a +`RunnerSession` with an `fs`, `exec()`, an optional `preview()`, and `dispose()`. + ## Guardrails - **`concurrency`** (optional, default 4) — max workers in flight; positive integer. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 7bda472..b7ad297 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -19,6 +19,13 @@ * - {@link personaAgent} / {@link personaWorkers} — materialize personas for a run * - {@link personaRoster} — describe personas to a planner * - {@link stackPersonas} — the built-in Vike + universal-orm personas + * + * The runner is the pluggable execution seam: a workspace (filesystem + shell + + * optional preview) where autopilot builds and runs an app. Shaped after Flue's + * `sandbox` so WebContainer / Docker / Flue drop in behind one interface. + * + * - {@link FakeRunner} — in-memory runner for tests + * - {@link runnerTools} — expose a booted session to an agent as sandbox tools */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -39,6 +46,25 @@ export { type PersonaSpec, type PersonaAgentOptions, } from './personas/index.js' +export { + FakeRunner, + FakeRunnerSession, + RunnerError, + runnerTools, + type Runner, + type RunnerSession, + type RunnerFs, + type FileTree, + type BootOptions, + type ExecOptions, + type ExecResult, + type Preview, + type PreviewOptions, + type FakeRunnerOptions, + type FakeExec, + type RecordedExec, + type RunnerToolsOptions, +} from './runner/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/runner/fake.test.ts b/packages/ai-autopilot/src/runner/fake.test.ts new file mode 100644 index 0000000..dda5cee --- /dev/null +++ b/packages/ai-autopilot/src/runner/fake.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { FakeRunner } from './fake.js' +import { RunnerError } from './types.js' + +describe('FakeRunner.boot', () => { + it('seeds the workspace with files and normalizes paths', async () => { + const runner = new FakeRunner() + const s = await runner.boot({ files: { './pages/+Page.jsx': 'PAGE', 'app.ts': 'APP' } }) + assert.equal(await s.fs.read('pages/+Page.jsx'), 'PAGE') + assert.equal(await s.fs.read('/app.ts'), 'APP') // leading slash normalized + assert.deepEqual(s.snapshot(), { 'pages/+Page.jsx': 'PAGE', 'app.ts': 'APP' }) + }) + + it('gives each session a distinct id and tracks them', async () => { + const runner = new FakeRunner() + const a = await runner.boot() + const b = await runner.boot() + assert.notEqual(a.id, b.id) + assert.deepEqual(runner.sessions, [a, b]) + }) +}) + +describe('FakeRunnerSession.fs', () => { + it('writes, reads, checks existence, lists, and removes', async () => { + const s = await new FakeRunner().boot() + assert.equal(await s.fs.exists('a.txt'), false) + await s.fs.write('src/a.txt', 'A') + await s.fs.write('src/b.txt', 'B') + await s.fs.write('root.txt', 'R') + assert.equal(await s.fs.exists('src/a.txt'), true) + assert.deepEqual(await s.fs.list('src'), ['src/a.txt', 'src/b.txt']) + assert.deepEqual(await s.fs.list(), ['root.txt', 'src/a.txt', 'src/b.txt']) + await s.fs.remove('src/a.txt') + assert.equal(await s.fs.exists('src/a.txt'), false) + }) + + it('throws reading a missing file', async () => { + const s = await new FakeRunner().boot() + await assert.rejects(() => s.fs.read('nope.txt'), RunnerError) + }) +}) + +describe('FakeRunnerSession.exec', () => { + it('returns exit 0 with empty output by default and records the call', async () => { + const s = await new FakeRunner().boot() + const r = await s.exec('pnpm build', { cwd: 'app' }) + assert.deepEqual(r, { stdout: '', stderr: '', exitCode: 0 }) + assert.deepEqual(s.execCalls, [{ command: 'pnpm build', opts: { cwd: 'app' } }]) + }) + + it('uses a programmable onExec', async () => { + const runner = new FakeRunner({ + onExec: cmd => + cmd.startsWith('pnpm build') + ? { stdout: 'built', stderr: '', exitCode: 0 } + : { stdout: '', stderr: 'unknown', exitCode: 1 }, + }) + const s = await runner.boot() + assert.equal((await s.exec('pnpm build')).stdout, 'built') + assert.equal((await s.exec('frobnicate')).exitCode, 1) + }) +}) + +describe('FakeRunnerSession.preview', () => { + it('returns a fake url on the requested port when supported', async () => { + const s = await new FakeRunner({ previewUrl: 'https://x.local' }).boot() + assert.equal(typeof s.preview, 'function') + assert.deepEqual(await s.preview!({ port: 5173 }), { url: 'https://x.local:5173', port: 5173 }) + assert.equal((await s.preview!()).port, 3000) // default port + }) + + it('omits the preview method when the runner cannot preview', async () => { + const s = await new FakeRunner({ preview: false }).boot() + assert.equal(s.preview, undefined) + }) +}) + +describe('FakeRunnerSession.dispose', () => { + it('marks disposed and blocks further exec/preview', async () => { + const s = await new FakeRunner().boot() + await s.dispose() + assert.equal(s.disposed, true) + await assert.rejects(() => s.exec('ls'), RunnerError) + await assert.rejects(() => s.preview!(), RunnerError) + }) +}) diff --git a/packages/ai-autopilot/src/runner/fake.ts b/packages/ai-autopilot/src/runner/fake.ts new file mode 100644 index 0000000..e883026 --- /dev/null +++ b/packages/ai-autopilot/src/runner/fake.ts @@ -0,0 +1,151 @@ +import type { + Runner, + RunnerSession, + RunnerFs, + BootOptions, + ExecOptions, + ExecResult, + Preview, + PreviewOptions, +} from './types.js' +import { RunnerError } from './types.js' + +/** How the fake responds to a command: a static result or a per-command function. */ +export type FakeExec = (command: string, opts: ExecOptions) => ExecResult | Promise + +export interface FakeRunnerOptions { + /** Canned `exec` behavior. Default: exit 0, empty stdout/stderr. */ + onExec?: FakeExec + /** Whether booted sessions can serve a preview. Default `true`. */ + preview?: boolean + /** Base URL returned by `preview()`. Default `https://preview.fake.local`. */ + previewUrl?: string +} + +/** Normalize a workspace path to a canonical relative form. */ +function norm(path: string): string { + return path.replace(/^\.?\/+/, '').replace(/\/+$/, '') +} + +class FakeFs implements RunnerFs { + constructor(private readonly files: Map) {} + + async read(path: string): Promise { + const p = norm(path) + if (!this.files.has(p)) throw new RunnerError(`no such file: ${path}`) + return this.files.get(p)! + } + + async write(path: string, contents: string): Promise { + this.files.set(norm(path), contents) + } + + async remove(path: string): Promise { + this.files.delete(norm(path)) + } + + async list(dir?: string): Promise { + const prefix = dir ? norm(dir) + '/' : '' + return [...this.files.keys()].filter(p => p.startsWith(prefix)).sort() + } + + async exists(path: string): Promise { + return this.files.has(norm(path)) + } +} + +/** A recorded `exec` invocation, for test assertions. */ +export interface RecordedExec { + command: string + opts: ExecOptions +} + +/** The session a {@link FakeRunner} boots — exposes its state for assertions. */ +export class FakeRunnerSession implements RunnerSession { + readonly id: string + readonly fs: FakeFs + /** Every `exec` call in order, so tests can assert what autopilot ran. */ + readonly execCalls: RecordedExec[] = [] + /** Set once `dispose()` has run. */ + disposed = false + + /** + * Present only when the runner supports previews — presence is the capability + * signal, so it is an own property assigned in the constructor (not a + * prototype method, which could not be conditionally omitted). + */ + readonly preview?: (opts?: PreviewOptions) => Promise + + private readonly files = new Map() + + constructor( + id: string, + boot: BootOptions, + private readonly opts: Required>, + ) { + this.id = id + for (const [path, contents] of Object.entries(boot.files ?? {})) { + this.files.set(norm(path), contents) + } + this.fs = new FakeFs(this.files) + if (opts.preview) { + this.preview = async (previewOpts: PreviewOptions = {}): Promise => { + if (this.disposed) throw new RunnerError('preview on a disposed session') + const port = previewOpts.port ?? 3000 + return { url: `${this.opts.previewUrl}:${port}`, port } + } + } + } + + /** A snapshot of the workspace files, for assertions. */ + snapshot(): Record { + return Object.fromEntries(this.files) + } + + async exec(command: string, opts: ExecOptions = {}): Promise { + if (this.disposed) throw new RunnerError('exec on a disposed session') + this.execCalls.push({ command, opts }) + return this.opts.onExec(command, opts) + } + + async dispose(): Promise { + this.disposed = true + } +} + +/** + * An in-memory {@link Runner} for tests: an in-memory filesystem, a programmable + * `exec`, and recorded calls — the runner analog of `ai-sdk`'s `AiFake`. Drive + * autopilot against it without any sandbox infra. + * + * ```ts + * const runner = new FakeRunner({ onExec: (cmd) => + * cmd.startsWith('pnpm build') ? { stdout: 'ok', stderr: '', exitCode: 0 } + * : { stdout: '', stderr: '', exitCode: 0 } }) + * const s = await runner.boot({ files: { 'pages/+Page.jsx': '…' } }) + * await s.exec('pnpm build') + * s.execCalls // → [{ command: 'pnpm build', opts: {} }] + * ``` + */ +export class FakeRunner implements Runner { + readonly kind = 'fake' + /** Every session this runner has booted. */ + readonly sessions: FakeRunnerSession[] = [] + + private readonly opts: Required> + private counter = 0 + + constructor(options: FakeRunnerOptions = {}) { + this.opts = { + onExec: options.onExec ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })), + preview: options.preview ?? true, + previewUrl: options.previewUrl ?? 'https://preview.fake.local', + } + } + + async boot(opts: BootOptions = {}): Promise { + const session = new FakeRunnerSession(`fake-session-${++this.counter}`, opts, this.opts) + this.sessions.push(session) + return session + } +} diff --git a/packages/ai-autopilot/src/runner/index.ts b/packages/ai-autopilot/src/runner/index.ts new file mode 100644 index 0000000..6a9e758 --- /dev/null +++ b/packages/ai-autopilot/src/runner/index.ts @@ -0,0 +1,32 @@ +/** + * The runner — the pluggable execution seam of `@gemstack/ai-autopilot`. + * + * A {@link Runner} boots an isolated workspace (filesystem + shell + optional + * preview) where autopilot builds and runs an app. It is shaped after Flue's + * `sandbox` so real sandboxes (WebContainer, Docker, Flue) drop in behind one + * interface. This module ships the interface plus a {@link FakeRunner} for + * tests; real adapters land separately. + * + * - {@link FakeRunner} — in-memory runner for tests (the runner analog of `AiFake`) + * - {@link runnerTools} — expose a session to an agent as sandbox tools + */ +export type { + Runner, + RunnerSession, + RunnerFs, + FileTree, + BootOptions, + ExecOptions, + ExecResult, + Preview, + PreviewOptions, +} from './types.js' +export { RunnerError } from './types.js' +export { + FakeRunner, + FakeRunnerSession, + type FakeRunnerOptions, + type FakeExec, + type RecordedExec, +} from './fake.js' +export { runnerTools, type RunnerToolsOptions } from './tools.js' diff --git a/packages/ai-autopilot/src/runner/tools.test.ts b/packages/ai-autopilot/src/runner/tools.test.ts new file mode 100644 index 0000000..1e380e1 --- /dev/null +++ b/packages/ai-autopilot/src/runner/tools.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { AnyTool } from '@gemstack/ai-sdk' +import { FakeRunner } from './fake.js' +import { runnerTools } from './tools.js' + +/** Invoke a tool's server handler directly. */ +function call(tools: AnyTool[], name: string, input: unknown) { + const tool = tools.find(t => t.definition.name === name) + assert.ok(tool, `tool ${name} exists`) + assert.ok(tool!.execute, `tool ${name} has a server handler`) + return (tool!.execute as (i: unknown) => unknown)(input) +} + +describe('runnerTools', () => { + it('round-trips write_file → read_file through the session', async () => { + const s = await new FakeRunner().boot() + const tools = runnerTools(s) + await call(tools, 'write_file', { path: 'pages/+Page.jsx', contents: 'PAGE' }) + assert.equal(s.snapshot()['pages/+Page.jsx'], 'PAGE') + assert.equal(await call(tools, 'read_file', { path: 'pages/+Page.jsx' }), 'PAGE') + }) + + it('exec tool runs the command in the session', async () => { + const runner = new FakeRunner({ onExec: () => ({ stdout: 'ok', stderr: '', exitCode: 0 }) }) + const s = await runner.boot() + const r = (await call(runnerTools(s), 'exec', { command: 'pnpm build', cwd: 'app' })) as { + exitCode: number + } + assert.equal(r.exitCode, 0) + assert.deepEqual(s.execCalls, [{ command: 'pnpm build', opts: { cwd: 'app' } }]) + }) + + it('includes preview only when the session supports it', async () => { + const withPreview = runnerTools(await new FakeRunner().boot()).map(t => t.definition.name) + assert.ok(withPreview.includes('preview')) + const without = runnerTools(await new FakeRunner({ preview: false }).boot()).map( + t => t.definition.name, + ) + assert.ok(!without.includes('preview')) + }) + + it('honors the write/exec toggles and the name prefix', async () => { + const s = await new FakeRunner().boot() + const names = runnerTools(s, { write: false, exec: false, prefix: 'sandbox' }).map( + t => t.definition.name, + ) + assert.deepEqual(names.sort(), ['sandbox_list_files', 'sandbox_preview', 'sandbox_read_file']) + }) +}) diff --git a/packages/ai-autopilot/src/runner/tools.ts b/packages/ai-autopilot/src/runner/tools.ts new file mode 100644 index 0000000..b6aea9e --- /dev/null +++ b/packages/ai-autopilot/src/runner/tools.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' +import { toolDefinition, type AnyTool } from '@gemstack/ai-sdk' +import type { RunnerSession } from './types.js' + +/** Control which sandbox tools are exposed to the agent. */ +export interface RunnerToolsOptions { + /** + * Prefix for every tool name (e.g. `sandbox` → `sandbox_exec`). Useful when a + * persona already has a same-named tool. Default: no prefix. + */ + prefix?: string + /** Expose `write_file` / `remove_file`. Default `true`. */ + write?: boolean + /** + * Expose the `exec` tool. Default `true`. Set `false` for a read-only surface + * (inspect files + preview, but no arbitrary command execution). + */ + exec?: boolean +} + +/** + * Expose a booted {@link RunnerSession} to an agent as `ai-sdk` tools, so a + * persona can act inside the sandbox: read/write files, run commands, and get a + * preview URL. This is the bridge between the runner seam and the agent layer. + * + * The `preview` tool is included only when the session supports it + * (`session.preview` is defined) — capability is detected, not assumed. + */ +export function runnerTools(session: RunnerSession, opts: RunnerToolsOptions = {}): AnyTool[] { + const name = (base: string) => (opts.prefix ? `${opts.prefix}_${base}` : base) + const tools: AnyTool[] = [] + + tools.push( + toolDefinition({ + name: name('read_file'), + description: 'Read a file from the sandbox workspace.', + inputSchema: z.object({ path: z.string().describe('Workspace-relative path.') }), + }).server(async ({ path }) => session.fs.read(path)) as unknown as AnyTool, + ) + + tools.push( + toolDefinition({ + name: name('list_files'), + description: 'List files in the sandbox workspace, optionally under a directory.', + inputSchema: z.object({ dir: z.string().optional().describe('Directory to list; root if omitted.') }), + }).server(async ({ dir }) => ({ files: await session.fs.list(dir) })) as unknown as AnyTool, + ) + + if (opts.write !== false) { + tools.push( + toolDefinition({ + name: name('write_file'), + description: 'Create or overwrite a file in the sandbox workspace.', + inputSchema: z.object({ + path: z.string().describe('Workspace-relative path.'), + contents: z.string().describe('Full file contents.'), + }), + }) + .server(async ({ path, contents }) => { + await session.fs.write(path, contents) + return { ok: true, path } + }) + .modelOutput(r => `wrote ${r.path}`) as unknown as AnyTool, + ) + + tools.push( + toolDefinition({ + name: name('remove_file'), + description: 'Delete a file from the sandbox workspace.', + inputSchema: z.object({ path: z.string().describe('Workspace-relative path.') }), + }) + .server(async ({ path }) => { + await session.fs.remove(path) + return { ok: true, path } + }) + .modelOutput(r => `removed ${r.path}`) as unknown as AnyTool, + ) + } + + if (opts.exec !== false) { + tools.push( + toolDefinition({ + name: name('exec'), + description: 'Run a shell command in the sandbox and return stdout, stderr, and exit code.', + inputSchema: z.object({ + command: z.string().describe('The shell command to run.'), + cwd: z.string().optional().describe('Working directory, relative to the workspace root.'), + }), + }) + .server(async ({ command, cwd }) => session.exec(command, cwd ? { cwd } : {})) + .modelOutput(r => `exit ${r.exitCode}\n${r.stdout}${r.stderr ? `\n${r.stderr}` : ''}`) as unknown as AnyTool, + ) + } + + if (session.preview) { + const preview = session.preview.bind(session) + tools.push( + toolDefinition({ + name: name('preview'), + description: 'Expose the running dev server and return its preview URL.', + inputSchema: z.object({ port: z.number().int().positive().optional().describe('Dev server port.') }), + }) + .server(async ({ port }) => preview(port ? { port } : {})) + .modelOutput(r => r.url) as unknown as AnyTool, + ) + } + + return tools +} diff --git a/packages/ai-autopilot/src/runner/types.ts b/packages/ai-autopilot/src/runner/types.ts new file mode 100644 index 0000000..8f67a45 --- /dev/null +++ b/packages/ai-autopilot/src/runner/types.ts @@ -0,0 +1,101 @@ +/** + * The pluggable execution seam. A `Runner` boots isolated workspaces where + * autopilot writes project files, runs commands, and (optionally) serves a live + * preview. It is modeled on Flue's `sandbox` contract so a real sandbox drops in + * behind one interface — this is the "sit on harnesses, don't compete" bet made + * concrete: WebContainer (instant in-browser Vike preview), a Docker sandbox on + * our servers, or a Flue sandbox (in-memory / edge / container) are all just + * implementations of `Runner`. + * + * This module is the interface + a `FakeRunner`; the real implementations land + * behind it as separate adapters. + */ + +/** A set of files to seed a workspace with: path (relative) → contents. */ +export type FileTree = Record + +/** Options for provisioning a fresh workspace. */ +export interface BootOptions { + /** Files written into the workspace before any command runs. */ + files?: FileTree + /** Default working directory for `exec`; defaults to the workspace root. */ + cwd?: string + /** Environment variables available to every `exec` in the session. */ + env?: Record +} + +/** Per-command overrides for {@link RunnerSession.exec}. */ +export interface ExecOptions { + /** Working directory, relative to the workspace root. */ + cwd?: string + /** Extra environment variables, merged over the session env. */ + env?: Record + /** Abort the command after this many milliseconds. */ + timeoutMs?: number +} + +/** The outcome of running a command. */ +export interface ExecResult { + stdout: string + stderr: string + /** Process exit code; `0` is success. */ + exitCode: number +} + +/** A virtual filesystem scoped to a single session's workspace. */ +export interface RunnerFs { + read(path: string): Promise + write(path: string, contents: string): Promise + remove(path: string): Promise + /** List entries under `dir` (workspace root when omitted). */ + list(dir?: string): Promise + exists(path: string): Promise +} + +/** Options for exposing a running dev server. */ +export interface PreviewOptions { + /** Port the server listens on inside the sandbox. */ + port?: number +} + +/** A reachable URL for the running app. */ +export interface Preview { + url: string + port: number +} + +/** + * One booted workspace: a virtual filesystem, a shell, and an optional preview. + * + * `preview` is optional by design — not every runner serves HTTP (a one-shot CI + * sandbox may only `exec`). Its presence *is* the capability signal: callers + * (and {@link runnerTools}) branch on whether `session.preview` is defined. + */ +export interface RunnerSession { + /** Stable id for this workspace. */ + readonly id: string + readonly fs: RunnerFs + exec(command: string, opts?: ExecOptions): Promise + /** Expose a running dev server and return its URL. Absent when unsupported. */ + preview?(opts?: PreviewOptions): Promise + /** Tear down the workspace and release its resources. Idempotent. */ + dispose(): Promise +} + +/** + * A pluggable execution environment. `boot()` provisions a fresh, isolated + * workspace and returns a {@link RunnerSession}. + */ +export interface Runner { + /** Identifies the implementation: `fake`, `flue`, `webcontainer`, `docker`, … */ + readonly kind: string + boot(opts?: BootOptions): Promise +} + +/** Thrown for runner misuse (unsupported operation, disposed session, …). */ +export class RunnerError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'RunnerError' + } +}