UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

420 lines 22.5 kB
import { Bootstrap, DockerRunner, LocalRunner, LoopEngine, dockerAvailable, builtinFrameworkPresetRegistry, mergeChecklists, serveCheck, } from '@gemstack/ai-autopilot'; import { snapshotWorkspace } from './sandbox.js'; import { composeRunSystem } from './system-prompt.js'; import { createRunControls, emitSessionStart, endStopDetail } from './run-telemetry.js'; import { createTurnSignalEmitter } from './turn-gate.js'; import { drainGates, runChatPhase } from './await-gate.js'; import { leaveResumeNote, runTodoLoop } from './todo-loop.js'; import { continueAfterChoice, decideDeploy, deployWith, domainLoopChecklist, driverBuild, driverImprove, driverLoopPrompts } from './steps.js'; import { OPEN_LOOP_MODES } from './events.js'; import { errorMessage } from './error-message.js'; /** * The framework's default full-fledged pass budget. Higher than ai-autopilot's * base of 3 because a from-scratch build spends its first pass or two just * bootstrapping an empty workspace before there is anything to polish (#182). */ export const DEFAULT_MAX_PASSES = 5; /** * Run the whole turnkey flow: detect the framework preset, frame the wrapped * agent with its framework skill (page builder + docs), then drive ai-autopilot's `Bootstrap` * (scope → build → full-fledged loop → deploy) entirely *through* * the driver (option A). Every phase, plus the agent's own progress, streams as * a {@link FrameworkEvent}. Reversible: swap in a real deploy target, or a * different `Driver`, without touching this wiring. */ export async function runFramework(opts) { const events = []; const emit = (event) => { events.push(event); if (opts.onEvent) { try { opts.onEvent(event); } catch (err) { console.error('[framework] onEvent threw; ignoring:', err); } } }; // 1. Detect the framework the project already uses. Detection only narrates // ("Detected Vike") and rides the result — nothing about it reaches the agent's // prompt (#547). const signals = opts.signals ?? {}; const { preset, detection } = builtinFrameworkPresetRegistry().select(signals); const domainPreset = opts.preset; // The built-in #326 system prompt + any user SYSTEM.md are the whole prompt. Only // the template's system half is used here: each Bootstrap step composes its own // prompt around the intent, so the user-prompt slot stays with the steps. // `tf.params.autopilot` reflects the run's autopilot mode (#325). const tf = { prompt: opts.intent, params: { autopilot: opts.modes?.includes('autopilot') ?? false, ...(opts.eco ? { eco: opts.eco } : {}) }, }; // The "read" half of the bind mechanism (#1121/#1129): a topic run's channel lists the projects // it can bind to. Read through the same injected seam the gate resolves against, so no `node:fs` // reaches this path; absent for a non-topic run, which gets no bind block at all. const topicProjects = opts.topic && opts.bind ? (await opts.bind.listProjects()).map(p => p.path) : undefined; // One assembly path for the whole system channel (#501), shared with the // direct-prompt path so the two can never drift (the drift behind #500). const system = composeRunSystem({ antiLazyPill: opts.antiLazyPill, browser: opts.browser, handsOff: opts.driver.handsOff === true, topic: opts.topic, ...(topicProjects ? { topicProjects } : {}), transparent: opts.transparent, user: opts.systemPrompt, tf, context: opts.context, }); emitSessionStart({ emit, driver: opts.driver, cwd: opts.cwd, sessionLink: opts.sessionLink, model: opts.model }); // Surface the exact system prompt the agent runs under (#343). Nothing is read // off disk and appended after this, so the text is the whole of it (#547). The // per-turn user prompts ride along as `driver` `start` events, so the dashboard // can show every prompt sent. if (system) emit({ kind: 'system-prompt', text: system }); emit({ kind: 'log', message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence})`, }); if (domainPreset) { const modeNote = opts.modes?.length ? ` (modes: ${opts.modes.join(', ')})` : ''; emit({ kind: 'log', message: `Domain preset: ${domainPreset.title}${modeNote}; ${domainPreset.loops.length}-loop review policy in effect`, }); // Surface the run's active modes as read-only checkboxes on the dashboard (#272). emit({ kind: 'modes', all: OPEN_LOOP_MODES, active: opts.modes ?? [] }); } // The run's abort plumbing and driver-event sink: the caller's signal composed with // the budget (#322), consumption (#529), and plan-decline (#358) self-stops. const { runSignal, onDriverEvent, consumptionTrip, budgetController, consumptionController, declineController } = createRunControls({ emit, signal: opts.signal, sessionLink: opts.sessionLink, budgetUsd: opts.budgetUsd, consumptionGate: opts.consumptionGate, }); // 2. One driver session for the whole run; each prompt is a fresh invocation. // A continuation (#1467) resumes the stopped leg's conversation instead of starting anew. const resuming = typeof opts.resumeSessionId === 'string' && opts.resumeSessionId.length > 0; const session = await opts.driver.start({ cwd: opts.cwd, system, ...(opts.model ? { model: opts.model } : {}), ...(resuming ? { resumeSessionId: opts.resumeSessionId } : {}), signal: runSignal, onEvent: onDriverEvent, }); // The domain preset's review policy (exposed on the result) and the review // checklist it drives — absent without a preset (#1372): the agent is a black // box, so nothing reviews its work unless the user opted in. const { loop, reviewChecklist } = buildReview(session, domainPreset, { ...(opts.buildEvent ? { buildEvent: opts.buildEvent } : {}), signal: runSignal, emit, }); // Boot-and-serve gate: provision a runner so the checklist can gate on the app // actually running. Local adopts (never deletes) the driver's cwd in place; docker // sandboxes the check in a throwaway container (#229). An injected runner wins over both. const sandbox = opts.sandbox ?? 'local'; const s = opts.serve; let runner; if (s) runner = opts.runner ?? (await provisionServeRunner(sandbox, opts.cwd, s, emit)); const checklist = withServeCheck(reviewChecklist, runner, s, { sandbox, cwd: opts.cwd, injectedRunner: opts.runner !== undefined, emit, }); // A real driver writes files to the workspace, so the build/improve steps can // detect an empty workspace and hard-scaffold it (#182). The fake driver writes // nothing (its whole workspace is always "empty"), so it opts out to stay // deterministic. const verifyWorkspace = opts.driver.name !== 'fake'; const workspaceOpt = verifyWorkspace ? { verifyWorkspace: true } : {}; // The shared deps of every agent-facing gate. const gateDeps = { ...(opts.requestChoice ? { requestChoice: opts.requestChoice } : {}), emit, signal: runSignal, onDecline: () => declineController.abort(new Error('[framework] plan declined')), // Topic runs only (#1121): resolves an await-bind-project / await-create-project gate. ...(opts.bind ? { bind: opts.bind } : {}), }; // A hand-off driver (#1225): the prompt leaves this machine and the reply never comes back, // so the build prompt is the entire run. Every phase after it — the review checklist, // improving against its blockers, the backlog gate, live chat — would be reading // the driver's own "handed off to <url>" summary as if the agent had written it. That is // what put a verdict-is-missing complaint and an unanswerable "Start the next // backlog item?" call on a dashboard whose agent was somewhere else entirely. So the phases // are dropped rather than fed: Bootstrap skips its whole loop when no `checklist` step is // given, which leaves scope -> build and nothing after it. A run with no preset and no // serve config has no checklist either (#1372), and skips the loop the same way. const handsOff = opts.driver.handsOff === true; let preview; try { const bootstrap = new Bootstrap({ maxPasses: opts.maxPasses ?? DEFAULT_MAX_PASSES, signal: runSignal, onEvent: (event) => emit({ kind: 'bootstrap', event }), steps: { scope: () => ({ scope: opts.scope ?? 'full', intent: opts.intent }), // Resuming (#1467): the intent IS the continuation message and goes out verbatim — the // resumed transcript already carries the build framing, so re-rendering it would stack // a second scope→build preamble onto a conversation that lived through the first. build: agentAwaitGate(driverBuild(session, { ...workspaceOpt, ...(resuming ? { prompt: (intent) => intent } : {}) }), session, gateDeps), ...(handsOff || !checklist ? {} : { checklist, improve: driverImprove(session, workspaceOpt) }), ...(opts.deploy ? { deploy: opts.deployTarget ? deployWith(opts.deploy, opts.deployTarget) : decideDeploy(opts.deploy), } : {}), }, }); const result = await bootstrap.run(); // The run controls (budget #322, decline #358, quota #529) abort between phases. // The review loop used to be the phase after the build and observed the abort for // free; with no checklist step the bootstrap can settle without ever re-checking // (#1372), so the run must look for itself before treating the result as a success. if (runSignal.aborted) { throw runSignal.reason instanceof Error ? runSignal.reason : new Error('[framework] run stopped'); } // The backlog loop (#323): with the build settled, consume the agent's own // TODO backlog one gated entry per turn until it is empty. Default on for // real drivers (the fake demo writes no backlog and must stay deterministic; // its reused tmp workspace could also carry stale files). The run signal // (Stop / budget cap #322) and the item cap bound it for unattended runs. let todo; if (!handsOff && (opts.todoLoop ?? opts.driver.name !== 'fake')) { todo = await runTodoLoop({ session, cwd: opts.cwd, emit, requestChoice: opts.requestChoice, signal: runSignal, maxItems: opts.todoMaxItems, }); } // The serve gate boots the app only to check it, then stops it. When the // caller opts in (keepAlive), boot it once more after success and leave it up // so the user can open it; the caller owns tearing it down (Ctrl+C). Failure // to boot is non-fatal. Default off, so a programmatic run never leaks a // process a caller that ignores `preview` would never stop. if (runner && s?.keepAlive) preview = await startAppPreview(runner, s, emit); // Live chat (#714): with the build settled, take the user's own messages, each continuing // the same session — draining what queued and ending on idle (#1390), or parked until Stop // for a terminal-dashboard run (stayOpenChat). // A hand-off run has no session here to continue: the CLI can start a cloud session and // pull one back, but it cannot send a second message to one, so staying open would offer // a composer whose every message answers itself. The run ends instead, with the link. if (opts.messages && !handsOff) { await runChatPhase(session, opts.messages, { text: '' }, { ...(opts.requestChoice ? { requestChoice: opts.requestChoice } : {}), emit, emitTurnSignals: createTurnSignalEmitter(emit), signal: runSignal, ...(opts.recordMessage ? { recordMessage: opts.recordMessage } : {}), }, opts.stayOpenChat === true); } // Say why the run stops here, so a finished hand-off does not read as a run that gave up // one phase in. The link itself is already on the driver's `cloud <url>` action. if (handsOff) emit({ kind: 'log', message: 'Handed off: the rest of this run happens in its own session, which opens its own pull request.' }); emit({ kind: 'end', ok: true }); return { result, detection, events, ...(preview ? { preview } : {}), ...(loop ? { loop } : {}), ...(todo ? { todo } : {}) }; } catch (err) { const { stopped, detail } = await endStopDetail({ err, ...(opts.signal ? { signal: opts.signal } : {}), budgetController, consumptionController, declineController, consumptionTrip, ...(opts.budgetUsd != null ? { budgetUsd: opts.budgetUsd } : {}), leaveResumeNote: () => leaveResumeNote(opts.cwd, events, emit), }); emit({ kind: 'end', ok: false, ...(stopped ? { stopped: true } : {}), detail }); throw err; } finally { await session.dispose(); // Keep the runner alive only when it owns a live preview handed to the caller. if (runner && !preview) await runner.dispose(); } } /** * Materialize the domain preset's review policy against the run's driver, and the review * checklist it drives (#252) — each pass fires the preset's review chain through the * driver. Without a preset there is no review checklist at all (#1372): the agent is * treated as a black box, so the run reviews only what the user opted into. The build * event kind: an explicit run choice wins, else the preset's own default, else * `major-change` — how a `bug-fix` run reaches the preset's bug-fix loop (#265). The * `loop` is returned so the caller can expose it on the result. */ function buildReview(session, domainPreset, ctx) { const loop = domainPreset ? new LoopEngine({ loops: [...domainPreset.loops], prompts: driverLoopPrompts(session, domainPreset.prompts, { signal: ctx.signal }), }) : undefined; const buildEvent = ctx.buildEvent ?? domainPreset?.defaultEvent ?? 'major-change'; const reviewChecklist = loop ? domainLoopChecklist(loop, { kind: buildEvent }) : undefined; if (loop && domainPreset) { ctx.emit({ kind: 'log', message: `Review policy: the ${domainPreset.title} loop drives the ${buildEvent} review` }); } return { loop, reviewChecklist }; } /** * Union the review checklist with a boot-and-serve gate (#229) when the run has a runner: * `serveCheck` verifies the app actually boots, and `mergeChecklists` runs it alongside * the review. A docker sandbox re-seeds the container from the host source before every * check (the build writes to the host each pass); local reads the host dir live, so it * needs no sync. Without a runner/serve the review checklist stands alone — which may be * no checklist at all (#1372: no preset, no serve → nothing gates the build). */ function withServeCheck(review, runner, serve, ctx) { if (!runner || !serve) return review; const check = serveCheck(runner, { serve: serve.command, ...(serve.install ? { install: serve.install } : {}), ...(serve.build ? { build: serve.build } : {}), ...(serve.port !== undefined ? { port: serve.port } : {}), ...(serve.waitMs !== undefined ? { waitMs: serve.waitMs } : {}), ...(serve.healthPath ? { healthPath: serve.healthPath } : {}), onProgress: message => ctx.emit({ kind: 'log', message: `serve: ${message}` }), }); const serveStep = ctx.sandbox === 'docker' && !ctx.injectedRunner ? syncThenServe(runner, ctx.cwd, check, ctx.emit) : check; return review ? mergeChecklists(review, serveStep) : serveStep; } /** * The agent-authored await gate (#337 / #339): the turn-boundary counterpart to the * framework-emitted plan-approval gate (#304). When a build turn ends by asking the * user — an `await-choices` (pick one), `await-multiselect` (pick any), or * `await-confirmation` (approve/decline a plan, #358) block per * {@link AWAIT_PROTOCOL}, e.g. the #326 alternatives flow or the [Research] preset (#331) * — rather than finishing, show it, wait for the answer, and re-prompt the driver to * continue from that decision. A no-op unless a {@link RunFrameworkOptions.requestChoice} * handler is wired (headless byte-identical), and unless the agent actually stopped to * ask (the common case returns straight through). Bounded so an agent that keeps asking * can't loop forever. */ function agentAwaitGate(base, session, deps) { return async (ctx) => { const { requestChoice, emit } = deps; // Non-blocking signals the agent emitted this turn: markdown views (#441) pushed to the // rail, and the #326 lifecycle signals (session name, ready-for-merge) that flip the run's // dashboard status. None stop the turn. const emitTurnSignals = createTurnSignalEmitter(emit); let run = await base(ctx); emitTurnSignals(run.text); // The run controls (budget #322, quota #529) abort on the build turn's own usage. // The build can be the bootstrap's last step (#1372: no checklist without a preset or // serve config), so a stop the loop would once have caught must throw here — otherwise // the bootstrap settles an aborted run as done. const throwIfStopped = () => { if (!deps.signal?.aborted) return; throw deps.signal.reason instanceof Error ? deps.signal.reason : new Error('[framework] run stopped'); }; // Headless: nobody to ask, so the build's turn stands as it is rather than auto-answering // its own question. (The prompt paths differ here — they resolve to the recommended pick.) if (!requestChoice) { throwIfStopped(); return run; } const drained = await drainGates(run, { ...deps, emitTurnSignals }, (question, answer) => continueAfterChoice(session, ctx, question, answer)); // A declined plan (#358) ends the build here rather than re-prompting: the user takes over // with fresh instructions (e.g. a new run from the dashboard). if (drained.declined) deps.onDecline?.(); // The agent kept asking past the limit: proceed with the latest turn rather than loop. else if (drained.exhausted) emit({ kind: 'log', message: 'Proceeding with the build (await limit reached).' }); throwIfStopped(); return drained.turn; }; } /** * Boot the generated app in the adopted runner and keep it serving. Reuses the * same {@link ServeConfig} the serve gate used (deps are already installed from * the gate), so this only `start`s the server and `preview`s the port. Returns a * handle that stops the app and frees the runner; on any failure it narrates and * returns `undefined` so a run never fails just because the demo preview didn't * come up. */ async function startAppPreview(runner, serve, emit) { if (!runner.start || !runner.preview) return undefined; let proc; try { proc = await runner.start(serve.command); const { url } = await runner.preview({ port: serve.port ?? 3000, waitMs: serve.waitMs ?? 15_000, }); emit({ kind: 'preview', url, command: serve.command }); let stopped = false; return { url, command: serve.command, stop: async () => { if (stopped) return; stopped = true; try { await proc?.stop(); } finally { await runner.dispose(); } }, }; } catch (err) { emit({ kind: 'log', message: `preview: could not boot the app (${errorMessage(err)})` }); // Leave cleanup to the caller's finally (runner.dispose stops leftovers). return undefined; } } /** * Provision the runner the serve gate verifies in (#229). `local` adopts the host * cwd in place (dispose leaves it); `docker` boots a throwaway container the check * seeds and tears down. Fails fast with a clear message when docker is requested * but not reachable, so the run never limps on unsandboxed by surprise. */ async function provisionServeRunner(sandbox, cwd, serve, emit) { if (sandbox === 'docker') { if (!(await dockerAvailable())) { throw new Error('sandbox: --sandbox docker was requested but Docker is not reachable (need a running daemon and the `docker` CLI on PATH).'); } emit({ kind: 'log', message: 'sandbox: booting a Docker container for the serve check' }); // preview() publishes the container's fixed port, so it must match the port the // serve check previews on (serve.port, default 3000). return new DockerRunner({ previewPort: serve.port ?? 3000 }).boot(); } return new LocalRunner().adopt(cwd); } /** * Wrap a serve check so the sandbox is re-seeded with the host source before it * runs. The build happens on the host in this slice, so an isolated container has * to be synced each pass to see what the agent just wrote. */ function syncThenServe(runner, cwd, check, emit) { return async (ctx) => { const files = await snapshotWorkspace(cwd); for (const [path, contents] of Object.entries(files)) await runner.fs.write(path, contents); emit({ kind: 'log', message: `serve: synced ${Object.keys(files).length} file(s) into the sandbox` }); return check(ctx); }; } //# sourceMappingURL=run.js.map