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.
305 lines • 14.5 kB
JavaScript
import { PROTOCOLS_BROWSER, PROTOCOLS_AWAIT, PROTOCOLS_HANDS_OFF, PROTOCOLS_SIGNAL } from './prompts.generated.js';
/**
* The framework-owned "await" protocol (#337 / #339): the *code side* of the
* `showChoices()` / `showMultiSelect()` + `AWAIT` macros the system prompt delegates. The
* driver runs each agent turn as a black box to completion (#165), so the only way
* the framework can learn the agent stopped to ask (rather than deciding for itself)
* is a signal in the turn's final message. This appends one to the system prompt: it
* does not restate the macros, it pins *how* to emit an awaited choice so the
* turn-boundary gate can detect it. Kept minimal and self-contained so it survives the
* #326 wording still being written. The text lives in `prompts/protocols/await.md` (#551).
*/
export const AWAIT_PROTOCOL = PROTOCOLS_AWAIT;
/**
* Told to a hands-off agent only (#1234): the await gates {@link AWAIT_PROTOCOL} just taught are
* not available in this session, so an ambiguous prompt takes its most plausible reading instead
* of parking forever on a question nobody attached can answer. Worded as availability rather
* than as a rule, so it deletes itself cleanly once choices become a per-session capability.
* The text lives in `prompts/protocols/hands_off.md`.
*/
export const HANDS_OFF_PROTOCOL = PROTOCOLS_HANDS_OFF;
/**
* Told to the agent only when the agent has a browser (#824): that it has one, and that anything
* it needs to see or act on goes through the chrome-devtools tools rather than `WebFetch`.
* Lives in `prompts/protocols/browser.md`.
*/
export const BROWSER_PROTOCOL = PROTOCOLS_BROWSER;
/**
* The session-lifecycle protocol (#326): the code side of the `setSessionName()` and
* `setReadyForMerge()` actions the system prompt calls out. Like {@link AWAIT_PROTOCOL},
* it does not restate *when* to act — the system prompt owns that — it only pins *how* to
* emit the signal so the turn-boundary can detect it. Both are non-blocking: the agent
* emits the block and keeps going (the framework records it and reflects it in the
* dashboard). Injected alongside AWAIT_PROTOCOL. The text lives in
* `prompts/protocols/signal.md` (#551).
*/
export const SIGNAL_PROTOCOL = PROTOCOLS_SIGNAL;
/**
* How many times the agent may stop to ask, and be resumed, before an agent stops honoring
* gates and just finishes. A property of the await protocol, so every path that runs gates
* shares it: a build, a direct prompt, and the backlog loop each used to declare their own.
*/
export const MAX_AWAIT_ROUNDS = 5;
/**
* The prompt that resumes the agent after the user answers a gate. One wording for
* every path that runs gates (a direct prompt, the backlog loop, a build): the agent
* already knows what it is working on from the session, so the clause that used to
* vary per caller ("Continue" / "Continue the backlog entry" / "Continue building X")
* carried no distinct meaning to it. One constant so a reword lands everywhere at once
* instead of one path and not the others (#570).
*
* No "do not ask again" tail: a capable agent does not re-ask a settled question on
* its own, so spelling it out is babysitting we leave off until an agent shows it is
* needed (#570 review).
*/
export function continuationPrompt(question, answer) {
return `You paused to ask: "${question}". The user chose: ${answer}. Continue with that decision.`;
}
/**
* The log line for the other outcome: the user picked a `stop` option, so there is no
* continuation prompt and the session ends here (#358). Addressed to the user rather than to the
* agent — the agent is not told anything, which is the point — and it names the answer, because
* "stopped" on its own reads like a failure when it was a decision.
*/
export function stopMessage(answer) {
return `Stopped at your answer: ${answer}. Awaiting your instructions.`;
}
/** Slugify a title into a stable id, or `fallback` when it has no usable characters. */
function slugify(title, fallback) {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || fallback;
}
/**
* Parse every `show-markdown` block (per {@link AWAIT_PROTOCOL}) out of a turn's text
* (#441) — a non-blocking view the agent pushed to the side panel, so a turn may carry
* several and does not stop. Each block's first `# ` line is the title (the rest is the
* body); a block with no heading falls back to "Note". Blank blocks are skipped, and two
* blocks that slug to the same id keep the later one (an in-turn update). Never throws.
*/
export function parseMarkdownViews(text) {
const re = /```show-markdown\s+([\s\S]*?)```/g;
const byId = new Map();
for (const m of text.matchAll(re)) {
const body = (m[1] ?? '').trim();
if (!body)
continue;
const lines = body.split('\n');
const heading = /^#\s+(.+)/.exec(lines[0] ?? '');
const title = heading ? heading[1].trim() : 'Note';
const markdown = (heading ? lines.slice(1).join('\n') : body).trim();
if (!markdown)
continue;
const id = slugify(title, 'view');
byId.set(id, { id, title, markdown });
}
return [...byId.values()];
}
/**
* Parse the session name the agent set this turn (#326), from the last `set-session-name`
* block (per {@link SIGNAL_PROTOCOL}) — its first non-empty line, slugified to `[a-z0-9-]`
* so it matches the branch-name shape. Returns `undefined` when the agent did not set one
* (the common case) or the block is blank. A later block in the same turn wins (a rename).
*/
export function parseSessionName(text) {
const re = /```set-session-name\s+([\s\S]*?)```/g;
let name;
for (const m of text.matchAll(re)) {
const line = (m[1] ?? '').split('\n').map(l => l.trim()).find(Boolean);
// Emptiness is tested directly rather than via a fallback sentinel: a sentinel made a
// session legitimately named `view` indistinguishable from no name at all (#939).
const slug = line ? slugify(line, '') : '';
if (slug)
name = slug;
}
return name;
}
/**
* The longest first line still readable as a title (#1618). Past this the agent wrote a
* paragraph, not a name for its work, and the block is taken as body alone — a pull request
* whose title runs to a paragraph is a squash-merge subject that runs to a paragraph.
*/
const MAX_PR_TITLE = 100;
/**
* Parse the pull request the agent asked for this turn (#1567), from the last non-empty
* `open-pr` block (per {@link SIGNAL_PROTOCOL}). Returns `undefined` when the agent wrote none,
* which simply leaves the handoff describing the work itself. A later block in the same turn
* wins, so an agent may revise it as the work changes.
*
* The block is how an agent opens a pull request *through the framework* rather than by
* reaching for `gh` itself: the title and the description are the agent's, and the handoff keeps
* the parts that have to be consistent — the ticket's issue reference and recording the number
* on the agent.
*
* Shaped like a commit message, and read like one: the first line names the work, the rest
* describes it. A first line too long to be a name is not treated as one (#1618) — the block is
* all description then, and the title falls back to the session's name rather than being cut
* mid-sentence, which is how a raw prompt ended up as a permanent commit subject.
*/
export function parsePullRequest(text) {
let parsed;
for (const body of blocks(text, 'open-pr')) {
const trimmed = body.trim();
if (!trimmed)
continue;
const [first = '', ...rest] = trimmed.split('\n');
const title = first.trim();
const description = rest.join('\n').trim();
parsed =
title.length <= MAX_PR_TITLE
? { title, ...(description ? { description } : {}) }
: { description: trimmed };
}
return parsed;
}
/**
* Parse the errors the agent reported this turn (#1500), from every non-empty `error` block
* (per {@link SIGNAL_PROTOCOL}), in the order they were written.
*
* Unlike the other signals, every block is kept rather than only the last: two different things
* going wrong in one turn are two errors, and collapsing them would lose one. Blocks that are
* empty are skipped — an error with nothing to say is not an error.
*/
export function parseErrors(text) {
const errors = [];
for (const body of blocks(text, 'error')) {
const trimmed = body.trim();
if (!trimmed)
continue;
const [first = '', ...rest] = trimmed.split('\n');
const detail = rest.join('\n').trim();
errors.push({ headline: first.trim(), ...(detail ? { detail } : {}) });
}
return errors;
}
/**
* Whether the agent signalled `setReadyForMerge()` this turn (#326): the presence of a
* `ready-for-merge` block (per {@link SIGNAL_PROTOCOL}) anywhere in the text. Non-blocking
* and body-less — it just flips the agent from building to ready-for-review.
*/
export function parseReadyForMerge(text) {
return /```ready-for-merge(?:\s[\s\S]*?)?```/.test(text);
}
/** The bodies of every fenced `tag` block in `text`, in the order they appear. */
function blocks(text, tag) {
const re = new RegExp('```' + tag + '\\s+([\\s\\S]*?)```', 'g');
return [...text.matchAll(re)].map(m => m[1] ?? '');
}
/** Parse an await block's JSON body to a record, or `undefined` — the shared first step of
* every gate parser (it was written out three times). */
function parseRecord(body) {
let raw;
try {
raw = JSON.parse(body);
}
catch {
return undefined;
}
if (typeof raw !== 'object' || raw === null)
return undefined;
return raw;
}
/** Read a trimmed string field, or `''`. */
const str = (v) => (typeof v === 'string' ? v.trim() : '');
/**
* Parse the await gate a turn ended on (#337), from the last usable `await-choices` block in its
* text. Returns `undefined` when the agent just finished — the common case, so a normal build
* flows straight through.
*
* Tolerant by design, because a bad parse must never crash a build: ids are synthesized from
* position when the agent names none, a label-less option is dropped, a blank title falls back,
* `recommended` may be given as a label or an id, and a malformed block is ignored. A block whose
* options all fall away is not a gate — the agent carries on rather than parking on an empty question.
*/
export function parseAwaitGate(text) {
// Latest first, so the newest question wins — falling back to an earlier one when the agent's
// last block is malformed, rather than losing a good question to a bad one after it.
for (const body of blocks(text, 'await-choices').reverse()) {
const gate = parseGateBody(body);
if (gate)
return gate;
}
return undefined;
}
/** Parse one `await-choices` body, or `undefined` when there is nothing pickable in it. */
function parseGateBody(body) {
const record = parseRecord(body);
if (!record || !Array.isArray(record.options))
return undefined;
const options = [];
record.options.forEach((o, i) => {
const label = str(o?.label);
if (!label)
return;
const detail = str(o?.detail);
options.push({
id: str(o?.id) || `opt:${i}`,
label,
...(detail ? { detail } : {}),
...(o?.default === true ? { default: true } : {}),
...(o?.stop === true ? { stop: true } : {}),
});
});
if (options.length === 0)
return undefined;
const named = str(record.recommended);
const recommended = named ? (options.find(o => o.id === named) ?? options.find(o => o.label === named))?.id : undefined;
const file = str(record.file);
return {
title: str(record.title) || 'Which option?',
options,
...(recommended ? { recommended } : {}),
...(record.multi === true ? { multi: true } : {}),
...(file ? { file } : {}),
};
}
/**
* Emit the {@link PROTOCOLS_SIGNAL} signals an agent turn carries: markdown views, the errors it
* reported, the session name, `setReadyForMerge()`, and a pull-request description. Every turn the framework prompts goes through
* one of these, because the protocols are unconditional (see `composeAgentSystem`) — the
* agent is told it can signal on any turn, so any turn we don't parse drops the signal.
*
* The returned function holds the dedupe state for the turns it covers: `ready-for-merge`
* fires once, a session name and a pull-request description only re-emit on an actual
* change, and an error is logged once however often the agent restates it. Each caller makes one
* for its own span of turns (a build's await rounds, the whole backlog), so keep it for as
* many turns as should share that dedupe rather than making one per turn.
*/
export function createTurnSignalEmitter(emit) {
let named;
let ready = false;
let described;
const reported = new Set();
return (text) => {
for (const view of parseMarkdownViews(text))
emit({ kind: 'view', ...view });
for (const error of parseErrors(text)) {
// Reported once per span (#1500): agents restate their blocks turn after turn, and the
// same failure logged ten times reads as ten failures. The whole block keys it, so a
// second attempt that fails differently is still its own error.
const key = `${error.headline}\n${error.detail ?? ''}`;
if (reported.has(key))
continue;
reported.add(key);
emit({ kind: 'error', ...error });
}
const name = parseSessionName(text);
if (name && name !== named) {
named = name;
emit({ kind: 'session-name', name });
}
if (!ready && parseReadyForMerge(text)) {
ready = true;
emit({ kind: 'ready-for-merge' });
}
const pr = parsePullRequest(text);
const seen = pr && `${pr.title ?? ''}\n${pr.description ?? ''}`;
if (pr && seen !== described) {
described = seen;
emit({ kind: 'open-pr', ...pr });
}
};
}
//# sourceMappingURL=turn-gate.js.map