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
JavaScript
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