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.

197 lines 11.1 kB
import { composeAgentSystem, renderSystemPrompt } from './system-prompt.js'; import { createAgentControls, emitSessionStart, endStopDetail } from './agent-telemetry.js'; import { createTurnSignalEmitter } from './turn-gate.js'; import { runAwaitRounds } from './await-gate.js'; import { runTodoLoop } from './todo-loop.js'; import { buildPrompt, extendPrompt, isWorkspaceEmpty, scaffoldPrompt } from './steps.js'; import { isHandsOff } from './agent-location.js'; /** * Run one session to completion: send the opening prompt, honor each await gate (#337/#339) by * re-prompting with the user's answer, work the backlog if this is a build, and stay open for the * user's own messages when a chat source is wired. * * Emits the same {@link FrameworkEvent} stream throughout — `session`, `system-prompt`, `driver`, * `choice`, `usage`, `intent`, `end` — so the dashboard, the store, and the control channel (#344) * read one shape regardless of what opened the session. */ export async function runAgent(opts) { const events = []; const emit = (event) => { events.push(event); // Swallow a listener that throws: emit runs both inside and outside the try below (the // session-start and system-prompt events fire first), so an unguarded throw would escape // the session uncaught or skip its `end` event. if (opts.onEvent) { try { opts.onEvent(event); } catch (err) { console.error('[framework] onEvent threw; ignoring:', err); } } }; const kind = opts.kind ?? 'build'; // A hand-off location (#1225): the prompt leaves this machine and the reply never comes back, so // the opening turn is the entire session and every phase after it is dropped rather than fed. const handsOff = isHandsOff(opts.location); // The built-in #326 system prompt + any user SYSTEM.md frame the session (#301). const tf = { prompt: opts.prompt }; const system = composeAgentSystem({ vanilla: opts.vanilla, browser: opts.browser, handsOff, transparent: opts.transparent, user: opts.systemPrompt, tf, context: opts.context, }); emitSessionStart({ emit, driver: opts.driver, cwd: opts.cwd, sessionLink: opts.sessionLink, model: opts.model }); // What this session was asked for, so the store and the dashboard have its title without // parsing a prompt (#211). Nothing is read off disk and appended after the system prompt, so // the text emitted below is the whole of it (#547); the per-turn user prompts ride along as // `driver` `start` events, so every prompt sent is visible. emit({ kind: 'intent', text: opts.prompt }); if (system) emit({ kind: 'system-prompt', text: system }); // Usage accounting plus the one self-stop left (#358): the session signal composes the caller's // abort with the one an answer trips. Nothing stops a session for spending (E1) — that is // decided before it starts. const { agentSignal, onDriverEvent, answerController } = createAgentControls({ emit, signal: opts.signal, sessionLink: opts.sessionLink, }); // One driver session for the whole agent; each prompt is a fresh invocation. A continuation // (#720/#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: agentSignal, onEvent: onDriverEvent, }); // Non-blocking signals the agent emits per turn: markdown views (#441) and the session-lifecycle (#326) // signals (session name, ready-for-merge). None stop the turn. const emitTurnSignals = createTurnSignalEmitter(emit); try { const rounds = await runAwaitRounds({ session, prompt: openingPrompt(opts, kind, resuming, session.cwd), emitTurnSignals, requestChoice: opts.requestChoice, emit, signal: agentSignal, ...(resuming ? { resume: true } : {}), // Chat comes after the backlog for a build, so it is wired below rather than here. ...(opts.messages && (kind === 'prompt' || handsOff) ? { messages: opts.messages } : {}), ...(opts.stayOpenChat ? { stayOpenChat: true } : {}), }); // The agent kept asking past the limit: finish with the latest turn rather than loop. if (rounds.exhausted) emit({ kind: 'log', message: 'Finishing the session (await limit reached).' }); // An answer marked `stop` (#358) ends the session rather than carrying on: the user takes over // with fresh instructions, so building on a plan they just declined is the one thing not to // do. Tripped through the agent signal so every path ends the same way a Stop does — the prompt // path used to finish cleanly here instead, which meant the same decline read as a completed // session on one path and a stop on the other. if (rounds.stopped) answerController.abort(new Error('[framework] stopped by your answer')); let text = rounds.text; // #182: a build must actually produce an app. If nothing landed on disk the agent stalled // (e.g. sanity-checking the stack), so re-prompt once with a hard "create it from scratch" // directive. Only for a real driver — the fake one writes nothing, so its workspace always // reads empty — and only when the agent is not mid-question, which the gates just drained. if (kind === 'build' && !resuming && opts.driver.id !== 'fake' && !rounds.stopped && isWorkspaceEmpty(opts.cwd)) { const scaffolded = await session.prompt(scaffoldPrompt(opts.prompt), { signal: agentSignal }); emitTurnSignals(scaffolded.text); text = scaffolded.text; } // The session controls (a Stop, an answer that said stop #358) abort between turns, and the // opening rounds do not observe the abort themselves, so look before treating this as a // success — otherwise an aborted session settles as done. if (agentSignal.aborted) { throw agentSignal.reason instanceof Error ? agentSignal.reason : new Error('[framework] run stopped'); } // The backlog loop (#323): with the opening work settled, consume the agent's own TODO // backlog one gated entry per turn until it is empty. The session signal (Stop / budget cap // #322) and the item cap bound it for unattended sessions. let todo; if (kind === 'build' && !handsOff && (opts.todoLoop ?? opts.driver.id !== 'fake')) { todo = await runTodoLoop({ session, cwd: opts.cwd, emit, requestChoice: opts.requestChoice, signal: agentSignal, }); // A plan declined mid-backlog with a stop-marked answer ends the session, the same as #217. if (todo.sessionStopped) answerController.abort(new Error('[framework] stopped by your answer')); } // Live chat (#714) for a build, once its backlog is worked: a prompt session already took it // inside the rounds above, where there is nothing to come between. if (opts.messages && kind === 'build' && !handsOff && !agentSignal.aborted) { const chat = await runChatAfterBacklog(session, opts, emit, emitTurnSignals, agentSignal); text = chat.text; // Same as #217, for the chat leg: a stop here ends the session rather than publishing. if (chat.stopped) answerController.abort(new Error('[framework] stopped by your answer')); } // The backlog and chat legs abort the same signal a Stop does, but neither observes it itself // (as the opening rounds do not, #233). Look before settling as a success, or a stopped session // reaches `end ok:true` below and publishes the very work its answer declined. if (agentSignal.aborted) { throw agentSignal.reason instanceof Error ? agentSignal.reason : new Error('[framework] run stopped'); } // Say why a hand-off stops here, so a finished one does not read as a session that gave up a // turn 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 session happens in its own session, which opens its own pull request.' }); } emit({ kind: 'end', ok: true }); return { text, events, ...(todo ? { todo } : {}) }; } catch (err) { const { stopped, detail } = endStopDetail({ err, ...(opts.signal ? { signal: opts.signal } : {}), answerController }); emit({ kind: 'end', ok: false, ...(stopped ? { stopped: true } : {}), detail }); throw err; } finally { await session.dispose(); } } /** * The first thing the agent is sent. * * A resumed session (#720/#1467) gets the text verbatim: the `--resume`d transcript already * carries the framing, so composing it again would stack a second preamble onto a conversation * that lived through the first. So does a transparent or prompt-less session, which is what * "raw `claude -p`" means. A build is framed for the workspace it lands in: an existing codebase * is *extended*, not rebuilt from scratch (#185). */ function openingPrompt(opts, kind, resuming, cwd) { if (resuming || opts.transparent) return opts.prompt; if (kind === 'prompt') { return opts.vanilla ? opts.prompt : renderSystemPrompt({ prompt: opts.prompt }).user; } // Gated on a real driver, so the fake one (which writes nothing, so its workspace always reads // empty) always takes the greenfield path and stays deterministic. return opts.driver.id !== 'fake' && !isWorkspaceEmpty(cwd) ? extendPrompt(opts.prompt) : buildPrompt(opts.prompt); } /** The live-chat phase a build reaches after its backlog, sharing the rounds' own loop. */ async function runChatAfterBacklog(session, opts, emit, emitTurnSignals, signal) { const { runChatPhase } = await import('./await-gate.js'); const chat = await runChatPhase(session, opts.messages, { text: '' }, { ...(opts.requestChoice ? { requestChoice: opts.requestChoice } : {}), emit, emitTurnSignals, signal, }, opts.stayOpenChat === true); // `stopped` carries through, not just the text: an answer marked `stop` here must end the session // the same way it does in the opening rounds, or a stopped chat settles as a clean, published run. return { text: chat.turn.text, stopped: chat.stopped }; } //# sourceMappingURL=agent.js.map