Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ai-autopilot-runner.md
Original file line number Diff line number Diff line change
@@ -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).
36 changes: 36 additions & 0 deletions packages/ai-autopilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions packages/ai-autopilot/src/runner/fake.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
151 changes: 151 additions & 0 deletions packages/ai-autopilot/src/runner/fake.ts
Original file line number Diff line number Diff line change
@@ -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<ExecResult>

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<string, string>) {}

async read(path: string): Promise<string> {
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<void> {
this.files.set(norm(path), contents)
}

async remove(path: string): Promise<void> {
this.files.delete(norm(path))
}

async list(dir?: string): Promise<string[]> {
const prefix = dir ? norm(dir) + '/' : ''
return [...this.files.keys()].filter(p => p.startsWith(prefix)).sort()
}

async exists(path: string): Promise<boolean> {
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<Preview>

private readonly files = new Map<string, string>()

constructor(
id: string,
boot: BootOptions,
private readonly opts: Required<Pick<FakeRunnerOptions, 'onExec' | 'preview' | 'previewUrl'>>,
) {
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<Preview> => {
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<string, string> {
return Object.fromEntries(this.files)
}

async exec(command: string, opts: ExecOptions = {}): Promise<ExecResult> {
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<void> {
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<Pick<FakeRunnerOptions, 'onExec' | 'preview' | 'previewUrl'>>
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<FakeRunnerSession> {
const session = new FakeRunnerSession(`fake-session-${++this.counter}`, opts, this.opts)
this.sessions.push(session)
return session
}
}
32 changes: 32 additions & 0 deletions packages/ai-autopilot/src/runner/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading