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.

305 lines 14.5 kB
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