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.

137 lines 7.23 kB
import { hasSessionIdPlaceholder, resolveSessionLink } from './session-link.js'; import { UsageMeter } from './usage.js'; /** * Emit the run's opening `session` event. A literal link is shown right away; a * templated one (`.../{sessionId}`) can only resolve once the driver reports its * session id, so it waits for the `session-update` from {@link createDriverEventHandler}. */ export function emitSessionStart(opts) { const literal = opts.sessionLink && !hasSessionIdPlaceholder(opts.sessionLink) ? opts.sessionLink : undefined; opts.emit({ kind: 'session', driver: opts.driver.name, workspace: opts.cwd, fake: opts.driver.name === 'fake', ...(literal ? { sessionLink: literal } : {}), ...(opts.model ? { model: opts.model } : {}), }); } /** * Watch the driver's black box (#165) and turn it into the run's stream: surface the * real session id as `session-update` once known (that is the honest handle a UI links * to, and it changes per prompt, so re-emit), fold each turn's usage into the run total, * and trip the two self-stops. * * Both stops fire *after* the turn that crossed them: its cost is already spent, so the * point is to stop the next one. Each is signalled once, and the run's `AbortSignal.any` * composition carries it downstream. An agent that reports no price leaves `costUsd` * undefined and so can never trip the budget cap (#540). A consumption gate that throws * is treated as "carry on": an unreadable quota must not stop the work (#519), and the * gate is answered from a cached reading because a live one spawns the agent CLI (~5s). */ export function createDriverEventHandler(opts) { const { emit, budgetController, consumptionController } = opts; let lastSessionId; let consumptionTrip; const usage = new UsageMeter(); const onDriverEvent = (event) => { // The turn-start id announcement (#1322) is consumed here, not forwarded: it exists so the // `session-update` below lands before a Stop or a crash can lose the turn, and a transcript // row repeating an id the very next event also carries would only be noise. if (event.type === 'session') { if (event.sessionId !== lastSessionId) { lastSessionId = event.sessionId; const link = opts.sessionLink ? resolveSessionLink(opts.sessionLink, event.sessionId) : undefined; emit({ kind: 'session-update', sessionId: event.sessionId, ...(link ? { sessionLink: link } : {}) }); } return; } emit({ kind: 'driver', event }); if (event.type !== 'result') return; if (event.sessionId && event.sessionId !== lastSessionId) { lastSessionId = event.sessionId; // A driver that knows its session's real URL (#1317, the cloud hand-off) beats the // `--session-link` template, whose Claude default is the generic entry point. const link = event.sessionLink ?? (opts.sessionLink ? resolveSessionLink(opts.sessionLink, event.sessionId) : undefined); emit({ kind: 'session-update', sessionId: event.sessionId, ...(link ? { sessionLink: link } : {}) }); } if (!event.usage) return; usage.add(event.usage); const totals = usage.totals(); emit({ kind: 'usage', ...totals, ...(opts.budgetUsd != null ? { budgetUsd: opts.budgetUsd } : {}) }); if (opts.budgetUsd != null && totals.costUsd !== undefined && totals.costUsd >= opts.budgetUsd && !budgetController.signal.aborted) { emit({ kind: 'log', message: `Budget reached: $${totals.costUsd.toFixed(4)} of $${opts.budgetUsd} — stopping the session.` }); budgetController.abort(new Error('[framework] budget reached')); } if (opts.consumptionGate && !consumptionController.signal.aborted) { let reached = null; try { reached = opts.consumptionGate(); } catch (err) { console.error('[framework] consumptionGate threw; carrying on:', err); } if (reached) { consumptionTrip = reached; emit({ kind: 'log', message: `Quota boundary reached (${reached}) — pausing the session.` }); consumptionController.abort(new Error('[framework] quota boundary reached')); } } }; return { onDriverEvent, consumptionTrip: () => consumptionTrip }; } /** * Compose the run's signal and wire its driver-event handler in one place. The caller's * signal is OR'd (via {@link AbortSignal.any}) with three self-stops — the budget cap * (#322), a spent consumption window (#529), and a declined plan (#358) — so anything * downstream that watches `runSignal` stops the same way regardless of which fired. * Shared by the build (`run.ts`) and direct-prompt (`prompt-run.ts`) paths. */ export function createRunControls(opts) { const budgetController = new AbortController(); const declineController = new AbortController(); const consumptionController = new AbortController(); const runSignal = AbortSignal.any([ ...(opts.signal ? [opts.signal] : []), budgetController.signal, declineController.signal, consumptionController.signal, ]); const handler = createDriverEventHandler({ emit: opts.emit, sessionLink: opts.sessionLink, budgetUsd: opts.budgetUsd, consumptionGate: opts.consumptionGate, budgetController, consumptionController, }); return { ...handler, runSignal, budgetController, consumptionController, declineController }; } /** * Classify why a run's turn loop threw and render the `end` event's `detail`. A caller * interrupt, a budget cap (#322), a declined plan (#358), or a spent consumption window * (#529) are all clean stops; anything else is a real failure. The resume note is written * here (once `paused` is known) rather than at the trip, because it is file I/O racing the * run unwinding. Shared so the two run paths can never disagree on what "stopped" means. */ export async function endStopDetail(opts) { const callerAborted = opts.signal?.aborted === true; const budgetStopped = opts.budgetController.signal.aborted && !callerAborted; const declined = opts.declineController.signal.aborted; const paused = opts.consumptionController.signal.aborted && !callerAborted; const stopped = callerAborted || opts.budgetController.signal.aborted || declined || opts.consumptionController.signal.aborted; const resumeNote = paused ? await opts.leaveResumeNote() : undefined; const detail = declined ? 'plan declined' : budgetStopped ? `budget reached ($${opts.budgetUsd})` : paused ? `quota boundary reached${opts.consumptionTrip() ? ` (${opts.consumptionTrip()})` : ''}${resumeNote ? `; will resume from ${resumeNote}` : ''}` : opts.err instanceof Error ? opts.err.message : String(opts.err); return { stopped, detail }; } //# sourceMappingURL=run-telemetry.js.map