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
36 changes: 36 additions & 0 deletions examples/autopilot-quickstart/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# @gemstack/example-autopilot-quickstart

A runnable, end-to-end quickstart for [`@gemstack/ai-autopilot`](../../packages/ai-autopilot) — the four layers of the epic composed into one "build a feature" flow:

```
personas → Supervisor → runner (sandbox) → surfaces
```

A lead planner decomposes the task **"Add a paginated Orders page backed by an orders table"** and routes each subtask to a stack-aware **persona** (`universal-orm-modeler`, `vike-page-builder`, `ui-intent-designer`). The **Supervisor** dispatches them; each persona worker acts inside a **runner** sandbox — writing Vike/ORM files through `runnerTools` — and progress is rendered through the **surfaces** (a terminal sink for live output, plus a background handle exposing events + result).

It runs **offline**: `AiFake` scripts the model, so there's no API key and the output is deterministic.

## Run it

```bash
pnpm install && pnpm build # from the repo root, to build the packages first
pnpm --filter @gemstack/example-autopilot-quickstart start
```

You'll see the live plan, the files written into the sandbox, a build + preview URL, and the synthesized result.

## Test it

```bash
pnpm --filter @gemstack/example-autopilot-quickstart test
```

## Going real

The only fakes here are `AiFake` (the model) and `FakeRunner` (the sandbox). To run it for real, drop `AiFake` and give the personas real model strings, and swap `FakeRunner` for a real runner adapter (a `FlueRunner`, WebContainer, or Docker sandbox) — the `Runner` interface is the same, so nothing else in the flow changes.

## What each file shows

- **`src/autopilot.ts`** — the composition: `personaWorkers` + `runnerTools`, `agentPlanner` fed the `personaRoster`, a `Supervisor`, and `launchAutopilot` + `terminalSink` for the surfaces.
- **`src/main.ts`** — the runnable demo.
- **`src/autopilot.test.ts`** — asserts the four layers actually compose (plan routes by persona, files land in the sandbox, surfaces capture events).
23 changes: 23 additions & 0 deletions examples/autopilot-quickstart/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@gemstack/example-autopilot-quickstart",
"version": "0.0.0",
"private": true,
"description": "Runnable end-to-end quickstart for @gemstack/ai-autopilot: personas + Supervisor + runner + surfaces composed into one build-a-feature flow, offline via AiFake.",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && cd dist-test && node --test",
"clean": "rm -rf dist-test",
"start": "tsx src/main.ts"
},
"dependencies": {
"@gemstack/ai-autopilot": "workspace:^",
"@gemstack/ai-sdk": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsx": "^4.19.0",
"typescript": "^5.4.0"
}
}
41 changes: 41 additions & 0 deletions examples/autopilot-quickstart/src/autopilot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { runQuickstart, TASK } from './autopilot.js'

describe('autopilot quickstart: the four layers compose end-to-end', () => {
it('plans by persona, dispatches, acts in the sandbox, and surfaces progress', async () => {
const lines: string[] = []
const result = await runQuickstart(line => lines.push(line))

// Supervisor: three subtasks, each routed to a stack persona, all succeeded.
assert.equal(result.run.plan.length, 3)
assert.deepEqual(
result.run.plan.map(s => s.worker).sort(),
['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'],
)
assert.ok(result.run.results.every(r => r.ok), 'every subtask succeeded')

// Runner: each persona worker wrote its file into the sandbox via runnerTools.
assert.ok('database/schema.ts' in result.files)
assert.ok('pages/orders/+Page.jsx' in result.files)
assert.match(result.files['database/schema.ts']!, /orders/)
// the seed file is still there
assert.ok('package.json' in result.files)

// Runner: the post-build exec ran and a preview URL was exposed.
assert.equal(result.build.exitCode, 0)
assert.match(result.build.stdout, /built/)
assert.match(result.previewUrl, /:5173$/)

// Surfaces: the terminal sink printed a plan line and per-subtask lines;
// the background handle captured the same events.
assert.ok(lines.some(l => l.includes('plan:')), 'terminal printed the plan')
assert.ok(lines.some(l => l.includes('✓')), 'terminal printed a completed subtask')
assert.equal(result.events[0]!.type, 'plan')
assert.equal(result.events.at(-1)!.type, 'synthesize')
})

it('exposes the task constant for the runnable demo', () => {
assert.match(TASK, /Orders/)
})
})
150 changes: 150 additions & 0 deletions examples/autopilot-quickstart/src/autopilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { AiFake, agent, type ToolCall } from '@gemstack/ai-sdk'
import {
Supervisor,
agentPlanner,
stackPersonas,
personaRoster,
personaInstructions,
personaTools,
FakeRunner,
runnerTools,
terminalSink,
launchAutopilot,
type RunnerSession,
type SupervisorEvent,
type SupervisorRun,
} from '@gemstack/ai-autopilot'

/**
* The end-to-end shape of the ai-autopilot epic, in one flow:
*
* personas → Supervisor → runner (sandbox) → surfaces
*
* A lead planner decomposes a build task and routes each subtask to a
* stack-aware **persona**; the **Supervisor** dispatches them; each persona
* worker acts inside a **runner** sandbox (writing Vike/ORM files via
* `runnerTools`); progress is rendered through the **surfaces** (a terminal
* sink plus a background handle with a live stream).
*
* It runs offline: `AiFake` scripts the model, so there is no API key and the
* output is deterministic. Swapping `FakeRunner` for a real runner and dropping
* the fake is the only change needed to run it for real.
*/

/** The feature we ask autopilot to build. */
export const TASK = 'Add a paginated Orders page backed by an orders table'

/** Each subtask, the persona that should own it, and the file it writes. */
const WORK = [
{
worker: 'universal-orm-modeler',
description: 'Define the orders schema and a migration',
file: 'database/schema.ts',
contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n",
},
{
worker: 'vike-page-builder',
description: 'Build the /orders page that lists orders, paginated',
file: 'pages/orders/+Page.jsx',
contents: "export default function Page({ orders }) { return <OrderList orders={orders} /> }\n",
},
{
worker: 'ui-intent-designer',
description: 'Express the orders list as intent, not hardcoded markup',
file: 'pages/orders/+config.js',
contents: "export default { meta: { OrderList: { env: { server: true, client: true } } } }\n",
},
] as const

/** Script the fake: step 0 is the planner's JSON; then each worker writes its file. */
function scriptModel(fake: AiFake): void {
const plannerOutput = JSON.stringify(WORK.map(w => ({ description: w.description, worker: w.worker })))
const workerSteps = WORK.flatMap((w, i) => {
const toolCalls: ToolCall[] = [
{ id: `write-${i}`, name: 'write_file', arguments: { path: w.file, contents: w.contents } },
]
return [{ toolCalls }, { text: `Wrote ${w.file}` }]
})
// concurrency: 1 makes the provider-call order deterministic:
// 0 = planner, then each worker's (tool-call, final-text) pair in plan order.
fake.respondWithSequence([{ text: plannerOutput }, ...workerSteps])
}

/** Build one worker agent per persona, each with hands inside the sandbox. */
function personaWorkersWithSandbox(session: RunnerSession): Record<string, ReturnType<typeof agent>> {
const sandbox = runnerTools(session)
return Object.fromEntries(
stackPersonas.map(p => [
p.name,
agent({ instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }),
]),
)
}

export interface QuickstartResult {
run: SupervisorRun
/** Every event, in order (from the background handle). */
events: SupervisorEvent[]
/** Files the workers wrote into the sandbox. */
files: Record<string, string>
/** Output of the post-build `exec`. */
build: { stdout: string; exitCode: number }
/** The preview URL the sandbox exposed. */
previewUrl: string
}

/**
* Run the whole flow once and return everything the surfaces exposed.
*
* @param write where terminal-surface lines go (default: no-op; `main.ts` prints).
*/
export async function runQuickstart(write: (line: string) => void = () => {}): Promise<QuickstartResult> {
const fake = AiFake.fake()
scriptModel(fake)
try {
// Runner: an in-memory sandbox seeded with a minimal Vike project.
const runner = new FakeRunner({
onExec: cmd =>
cmd.includes('build')
? { stdout: 'orders page built', stderr: '', exitCode: 0 }
: { stdout: '', stderr: '', exitCode: 0 },
})
const session = await runner.boot({ files: { 'package.json': '{ "name": "shop" }\n' } })

// Personas → Supervisor. The planner is told the roster so it routes by role.
const planner = agentPlanner(
agent(`You are the lead engineer. Decompose the task and route each subtask to a persona.\n\n${personaRoster(stackPersonas)}`),
)
const start = (onEvent: (e: SupervisorEvent) => void) =>
new Supervisor({
plan: planner,
workers: personaWorkersWithSandbox(session),
concurrency: 1,
onEvent,
}).run(TASK)

// Surfaces: one run feeds both the terminal (live) and a background handle.
const terminal = terminalSink({ write })
const handle = launchAutopilot(onEvent =>
start(e => {
terminal(e)
onEvent(e)
}),
)
const run = await handle.result()

// Runner again: build the app and grab a preview URL.
const build = await session.exec('pnpm build')
const preview = session.preview ? await session.preview({ port: 5173 }) : { url: '' }

return {
run,
events: handle.events(),
files: session.snapshot(),
build: { stdout: build.stdout, exitCode: build.exitCode },
previewUrl: preview.url,
}
} finally {
fake.restore()
}
}
23 changes: 23 additions & 0 deletions examples/autopilot-quickstart/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { runQuickstart, TASK } from './autopilot.js'

/** Run the quickstart and print what each surface exposed. Offline (AiFake). */
async function main(): Promise<void> {
console.log(`Task: ${TASK}\n`)
console.log('--- live progress (terminal surface) ---')
const result = await runQuickstart(line => process.stdout.write(line + '\n'))

console.log('\n--- files written into the sandbox (runner) ---')
for (const path of Object.keys(result.files).sort()) console.log(` ${path}`)

console.log('\n--- build + preview (runner) ---')
console.log(` build: exit ${result.build.exitCode} — ${result.build.stdout}`)
console.log(` preview: ${result.previewUrl}`)

console.log('\n--- synthesized result (Supervisor) ---')
console.log(result.run.text.replace(/^/gm, ' '))
}

main().catch(err => {
console.error(err)
process.exitCode = 1
})
5 changes: 5 additions & 0 deletions examples/autopilot-quickstart/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true, "rootDir": "src" },
"include": ["src"]
}
5 changes: 5 additions & 0 deletions examples/autopilot-quickstart/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist-test", "rootDir": "src" },
"include": ["src"]
}
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading