From 27d0e6d493ec7cd8f59324450d6c2628d3f2ed03 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 19:23:21 +0300 Subject: [PATCH] feat(ai-autopilot): the loop, event-triggered prompt chains The agent declares a semantic LoopEvent (a change kind) and the matching prompt chain fires: major-change runs review + code-quality + security, ui-flow runs QA + UX. Loop.handle(event) resolves the chain from LoopRules (defineRule / defaultLoopRules as the built-in policy) and runs each LoopPrompt (definePrompt) for its passes with fresh context every pass; matches() is the pure preview, watch(stream) handles a stream fire-and-report. Design calls on the open questions: the trigger is agent-declared, not a heuristic; both modes ship (handle awaits; continueOnError:false is a blocking gate). Consults the decisions ledger via ctx.ledger; references the prompts library (#111) by id. Child #113 of the AI-framework epic (#110). --- .changeset/ai-autopilot-the-loop.md | 5 + packages/ai-autopilot/README.md | 44 +++++ packages/ai-autopilot/src/index.ts | 29 +++ packages/ai-autopilot/src/loop/define.test.ts | 33 ++++ packages/ai-autopilot/src/loop/define.ts | 46 +++++ packages/ai-autopilot/src/loop/index.ts | 23 +++ packages/ai-autopilot/src/loop/loop.test.ts | 151 +++++++++++++++ packages/ai-autopilot/src/loop/loop.ts | 179 ++++++++++++++++++ packages/ai-autopilot/src/loop/policy.test.ts | 17 ++ packages/ai-autopilot/src/loop/policy.ts | 44 +++++ packages/ai-autopilot/src/loop/types.ts | 120 ++++++++++++ 11 files changed, 691 insertions(+) create mode 100644 .changeset/ai-autopilot-the-loop.md create mode 100644 packages/ai-autopilot/src/loop/define.test.ts create mode 100644 packages/ai-autopilot/src/loop/define.ts create mode 100644 packages/ai-autopilot/src/loop/index.ts create mode 100644 packages/ai-autopilot/src/loop/loop.test.ts create mode 100644 packages/ai-autopilot/src/loop/loop.ts create mode 100644 packages/ai-autopilot/src/loop/policy.test.ts create mode 100644 packages/ai-autopilot/src/loop/policy.ts create mode 100644 packages/ai-autopilot/src/loop/types.ts diff --git a/.changeset/ai-autopilot-the-loop.md b/.changeset/ai-autopilot-the-loop.md new file mode 100644 index 0000000..206b960 --- /dev/null +++ b/.changeset/ai-autopilot-the-loop.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add "the loop": the event-to-prompt-chain policy. The agent declares a semantic `LoopEvent` (a change `kind`) and the matching prompt chain fires: a major change runs review + code-quality + security, a new UI flow runs QA + UX. `Loop.handle(event)` resolves the chain from `LoopRule`s (`defineRule` / `defaultLoopRules()` as the built-in policy) and runs each `LoopPrompt` (`definePrompt`) for its `passes` with fresh context every pass; `matches(event)` is the pure preview, `watch(stream)` handles a stream fire-and-report. Design calls on the two open questions: the trigger is agent-declared (not heuristic), and both modes ship (`handle` awaits; `continueOnError: false` is a blocking gate). Consults the decisions ledger via `ctx.ledger`, and references the prompts library (#111) by id. Verified end-to-end. Child #113 of the AI-framework epic (#110). diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index bdc43dc..279883e 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -203,6 +203,50 @@ behind the same contract later. The `LedgerFs` seam is a subset of the runner's `RunnerFs`, so the ledger persists inside a sandbox the same way it does on the host. This is the foundation "the loop" (#113) consults on major changes. +## The loop — event-triggered prompt chains + +The web-app-specific orchestration policy that generic harnesses do not have. +The agent declares a **semantic change** and the right follow-up prompts fire on +their own: a major change runs review + code-quality + security; a new UI flow +runs QA + UX. Semantic (a *kind* of change picks a *set* of prompts), not +command-driven and not run-on-every-PR. + +```ts +import { Loop, definePrompt, defaultLoopRules } from '@gemstack/ai-autopilot' + +const loop = new Loop({ + rules: defaultLoopRules(), // major-change → [review, code-quality, security]; ui-flow → [qa, ux] + prompts: [ + definePrompt({ id: 'review', passes: 2, run: ctx => runReview(ctx.event) }), + definePrompt({ id: 'code-quality', run: ctx => runQuality(ctx.event) }), + // ...register a prompt per id the rules reference + ], + ledger, // optional: exposed to each prompt via ctx.ledger (#112) + onEvent: e => log(e), // observe match / pass / done (observer-isolated) +}) + +await loop.handle({ kind: 'major-change', summary: 'reworked auth session handling', paths: ['src/auth/*'] }) +``` + +Design choices for the two open questions: + +- **What is a "major change"?** The agent **declares** it — the trigger is a + `LoopEvent { kind }` the worker emits, not a heuristic the loop guesses. It is + deterministic and it is the agent that knows intent. A heuristic classifier + (supervisor-event → `LoopEvent`) can sit in front of `handle` later. +- **Sync or async?** Both. `handle()` awaits the whole chain (the sync story); + `continueOnError: false` turns it into a **blocking gate** (a failing prompt + stops the chain). For fire-and-report over a stream, feed events through + `loop.watch(stream)`, or run `handle` inside `launchAutopilot` for a detached + background run. + +Each prompt runs for its `passes` with **fresh context every pass** (Rom's +finding: re-running the same prompt with a reset context improves the result), so +`run` is expected to build a new agent per call. `defaultLoopRules()` is the +built-in policy as data; extend it by concatenating your own `defineRule` results. +The prompt bodies themselves are the prompts library (#111), registered under the +ids the rules reference. + ## Guardrails - **`concurrency`** (optional, default 4) — max workers in flight; positive integer. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index aedc119..5ee5979 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -42,6 +42,14 @@ * - {@link DecisionLedger} — record decisions, consult before proposing * - {@link loadLedger} / {@link saveLedger} — persist to `DECISIONS.md` * - {@link decisionTools} / {@link decisionBriefing} — expose it to an agent + * + * The loop is the event-to-prompt-chain policy: the agent declares a semantic + * change (a {@link LoopEvent}) and the right follow-up prompts fire — a major + * change runs review + code-quality + security, a new UI flow runs QA + UX. + * + * - {@link Loop} — match an event to a prompt chain and run it (N fresh passes) + * - {@link definePrompt} / {@link defineRule} — author prompts and policy rules + * - {@link defaultLoopRules} — the built-in web-app policy */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -116,6 +124,27 @@ export { type DecisionStatus, type DecisionMatch, } from './decisions/index.js' +export { + definePrompt, + defineRule, + LoopError, + Loop, + createLoop, + defaultLoopRules, + LOOP_EVENTS, + LOOP_PROMPTS, + type LoopOptions, + type LoopEvent, + type LoopContext, + type LoopPrompt, + type LoopPromptSpec, + type LoopRule, + type LoopRuleSpec, + type PassResult, + type PromptOutcome, + type LoopRunResult, + type LoopProgress, +} from './loop/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/loop/define.test.ts b/packages/ai-autopilot/src/loop/define.test.ts new file mode 100644 index 0000000..8275672 --- /dev/null +++ b/packages/ai-autopilot/src/loop/define.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { definePrompt, defineRule, LoopError } from './define.js' + +describe('definePrompt', () => { + it('defaults passes to 1 and freezes', () => { + const p = definePrompt({ id: 'review', run: () => 'ok' }) + assert.equal(p.passes, 1) + assert.ok(Object.isFrozen(p)) + }) + + it('rejects a non-kebab id, a missing run, or a bad passes count', () => { + assert.throws(() => definePrompt({ id: 'Review', run: () => '' }), LoopError) + assert.throws(() => definePrompt({ id: 'review', run: undefined as never }), LoopError) + assert.throws(() => definePrompt({ id: 'review', run: () => '', passes: 0 }), LoopError) + assert.throws(() => definePrompt({ id: 'review', run: () => '', passes: 1.5 }), LoopError) + }) +}) + +describe('defineRule', () => { + it('normalizes a single `on` to a de-duped array', () => { + const r = defineRule({ on: 'major-change', run: ['review', 'security'] }) + assert.deepEqual(r.on, ['major-change']) + assert.deepEqual(r.run, ['review', 'security']) + assert.ok(Object.isFrozen(r)) + }) + + it('de-dupes kinds and requires non-empty on/run', () => { + assert.deepEqual(defineRule({ on: ['a', 'a', 'b'], run: ['x'] }).on, ['a', 'b']) + assert.throws(() => defineRule({ on: [], run: ['x'] }), LoopError) + assert.throws(() => defineRule({ on: 'a', run: [] }), LoopError) + }) +}) diff --git a/packages/ai-autopilot/src/loop/define.ts b/packages/ai-autopilot/src/loop/define.ts new file mode 100644 index 0000000..086efcd --- /dev/null +++ b/packages/ai-autopilot/src/loop/define.ts @@ -0,0 +1,46 @@ +import type { LoopPrompt, LoopPromptSpec, LoopRule, LoopRuleSpec } from './types.js' + +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** Thrown when a loop prompt or rule is malformed. Fails fast at definition. */ +export class LoopError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'LoopError' + } +} + +/** + * Validate a {@link LoopPromptSpec} and return a frozen {@link LoopPrompt}. + * `passes` defaults to 1 and must be a positive integer. + */ +export function definePrompt(spec: LoopPromptSpec): LoopPrompt { + const id = spec.id?.trim() + if (!id) throw new LoopError('prompt id is required') + if (!KEBAB.test(id)) throw new LoopError(`prompt id must be kebab-case: ${JSON.stringify(spec.id)}`) + if (typeof spec.run !== 'function') throw new LoopError(`prompt "${id}" needs a run function`) + + const passes = spec.passes ?? 1 + if (!Number.isInteger(passes) || passes < 1) { + throw new LoopError(`prompt "${id}" passes must be a positive integer, got ${spec.passes}`) + } + + return Object.freeze({ id, passes, run: spec.run }) +} + +/** + * Validate a {@link LoopRuleSpec} and return a frozen {@link LoopRule}. `on` is + * normalized to a de-duped list of event kinds; `run` is the ordered prompt ids. + */ +export function defineRule(spec: LoopRuleSpec): LoopRule { + const kinds = (Array.isArray(spec.on) ? spec.on : [spec.on]).map(k => k?.trim()).filter(Boolean) + if (kinds.length === 0) throw new LoopError('rule `on` needs at least one event kind') + + const run = (spec.run ?? []).map(p => p?.trim()).filter(Boolean) + if (run.length === 0) throw new LoopError(`rule on [${kinds.join(', ')}] needs at least one prompt in \`run\``) + + return Object.freeze({ + on: Object.freeze([...new Set(kinds)]), + run: Object.freeze([...run]), + }) +} diff --git a/packages/ai-autopilot/src/loop/index.ts b/packages/ai-autopilot/src/loop/index.ts new file mode 100644 index 0000000..bd78bab --- /dev/null +++ b/packages/ai-autopilot/src/loop/index.ts @@ -0,0 +1,23 @@ +/** + * The loop — the event-to-prompt-chain policy of `@gemstack/ai-autopilot`. + * + * The agent declares a {@link LoopEvent} (a semantic change kind); a + * {@link LoopRule} maps that kind to an ordered chain of {@link LoopPrompt}s; + * the {@link Loop} runs them (N fresh-context passes each) and consults the + * decisions ledger. {@link defaultLoopRules} is the built-in web-app policy. + */ +export { definePrompt, defineRule, LoopError } from './define.js' +export { Loop, createLoop, type LoopOptions } from './loop.js' +export { defaultLoopRules, LOOP_EVENTS, LOOP_PROMPTS } from './policy.js' +export type { + LoopEvent, + LoopContext, + LoopPrompt, + LoopPromptSpec, + LoopRule, + LoopRuleSpec, + PassResult, + PromptOutcome, + LoopRunResult, + LoopProgress, +} from './types.js' diff --git a/packages/ai-autopilot/src/loop/loop.test.ts b/packages/ai-autopilot/src/loop/loop.test.ts new file mode 100644 index 0000000..b7664eb --- /dev/null +++ b/packages/ai-autopilot/src/loop/loop.test.ts @@ -0,0 +1,151 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { Loop, createLoop } from './loop.js' +import { definePrompt, defineRule } from './define.js' +import { defaultLoopRules } from './policy.js' +import { DecisionLedger } from '../decisions/ledger.js' +import type { LoopContext, LoopProgress } from './types.js' + +/** A prompt that records the contexts it was called with. */ +function spyPrompt(id: string, passes?: number) { + const calls: LoopContext[] = [] + const prompt = definePrompt({ + id, + ...(passes ? { passes } : {}), + run: ctx => { + calls.push(ctx) + return `${id}#${ctx.pass}` + }, + }) + return { prompt, calls } +} + +describe('Loop — matching', () => { + const loop = new Loop({ rules: defaultLoopRules(), prompts: [] }) + + it('resolves the chain for a kind, in order and de-duped across rules', () => { + assert.deepEqual(loop.matches({ kind: 'major-change' }), ['review', 'code-quality', 'security']) + assert.deepEqual(loop.matches({ kind: 'ui-flow' }), ['qa', 'ux']) + assert.deepEqual(loop.matches({ kind: 'nothing' }), []) + }) + + it('concatenates and de-dupes when two rules match the same kind', () => { + const l = new Loop({ + rules: [defineRule({ on: 'x', run: ['a', 'b'] }), defineRule({ on: 'x', run: ['b', 'c'] })], + prompts: [], + }) + assert.deepEqual(l.matches({ kind: 'x' }), ['a', 'b', 'c']) + }) +}) + +describe('Loop — dispatch', () => { + it('runs the matched chain in order and reports outcomes', async () => { + const review = spyPrompt('review') + const security = spyPrompt('security') + const order: string[] = [] + const loop = new Loop({ + rules: [defineRule({ on: 'major-change', run: ['review', 'security'] })], + prompts: [ + definePrompt({ id: 'review', run: () => { order.push('review'); return 'r' } }), + definePrompt({ id: 'security', run: () => { order.push('security'); return 's' } }), + ], + }) + const result = await loop.handle({ kind: 'major-change', summary: 'x' }) + assert.equal(result.matched, true) + assert.deepEqual(order, ['review', 'security']) + assert.deepEqual(result.outcomes.map(o => o.promptId), ['review', 'security']) + assert.ok(result.outcomes.every(o => o.ok)) + void review; void security + }) + + it('reports matched:false and runs nothing when no rule fires', async () => { + const events: LoopProgress[] = [] + const loop = createLoop({ rules: defaultLoopRules(), prompts: [], onEvent: e => events.push(e) }) + const result = await loop.handle({ kind: 'unrelated' }) + assert.equal(result.matched, false) + assert.deepEqual(result.outcomes, []) + assert.equal(events[0]?.type, 'no-match') + }) + + it('runs N fresh-context passes, each with an incrementing pass number', async () => { + const { prompt, calls } = spyPrompt('review', 3) + const loop = new Loop({ rules: [defineRule({ on: 'c', run: ['review'] })], prompts: [prompt] }) + const result = await loop.handle({ kind: 'c' }) + assert.deepEqual(calls.map(c => c.pass), [1, 2, 3]) + assert.ok(calls.every(c => c.passes === 3)) + assert.deepEqual(result.outcomes[0]?.passes.map(p => p.text), ['review#1', 'review#2', 'review#3']) + }) + + it('flags a rule that references an unknown prompt without throwing', async () => { + const events: LoopProgress[] = [] + const loop = new Loop({ + rules: [defineRule({ on: 'c', run: ['ghost'] })], + prompts: [], + onEvent: e => events.push(e), + }) + const result = await loop.handle({ kind: 'c' }) + assert.equal(result.outcomes[0]?.ok, false) + assert.ok(events.some(e => e.type === 'unknown-prompt' && e.promptId === 'ghost')) + }) +}) + +describe('Loop — failure policy', () => { + const failing = definePrompt({ id: 'review', run: () => { throw new Error('boom') } }) + const after = definePrompt({ id: 'security', run: () => 'ran' }) + const rules = [defineRule({ on: 'major-change', run: ['review', 'security'] })] + + it('continues past a failure by default (fire-and-report)', async () => { + const loop = new Loop({ rules, prompts: [failing, after] }) + const result = await loop.handle({ kind: 'major-change' }) + assert.equal(result.outcomes[0]?.ok, false) + assert.equal(result.outcomes[1]?.ok, true) // security still ran + assert.match(String((result.outcomes[0]?.passes[0]?.error as Error).message), /boom/) + }) + + it('stops the chain on failure when continueOnError is false (gate)', async () => { + const events: LoopProgress[] = [] + const loop = new Loop({ rules, prompts: [failing, after], continueOnError: false, onEvent: e => events.push(e) }) + const result = await loop.handle({ kind: 'major-change' }) + assert.equal(result.outcomes.length, 1) // security was gated out + assert.ok(events.some(e => e.type === 'gate-stop' && e.promptId === 'review')) + }) +}) + +describe('Loop — decisions + watch', () => { + it('exposes the ledger to prompts via context', async () => { + const ledger = new DecisionLedger() + ledger.reject('Use Redux', 'boilerplate') + let seen: DecisionLedger | undefined + const loop = new Loop({ + rules: [defineRule({ on: 'c', run: ['review'] })], + prompts: [definePrompt({ id: 'review', run: ctx => { seen = ctx.ledger; return '' } })], + ledger, + }) + await loop.handle({ kind: 'c' }) + assert.equal(seen?.wasRejected('add redux'), true) + }) + + it('watch() handles a stream of events in order', async () => { + const loop = new Loop({ + rules: [defineRule({ on: 'c', run: ['review'] })], + prompts: [definePrompt({ id: 'review', run: ctx => ctx.event.summary ?? '' })], + }) + const results = await loop.watch([ + { kind: 'c', summary: 'first' }, + { kind: 'nope' }, + { kind: 'c', summary: 'second' }, + ]) + assert.deepEqual(results.map(r => r.matched), [true, false, true]) + assert.equal(results[0]?.outcomes[0]?.passes[0]?.text, 'first') + }) + + it('isolates a throwing onEvent callback', async () => { + const loop = new Loop({ + rules: [defineRule({ on: 'c', run: ['review'] })], + prompts: [definePrompt({ id: 'review', run: () => 'ok' })], + onEvent: () => { throw new Error('observer bug') }, + }) + const result = await loop.handle({ kind: 'c' }) + assert.equal(result.outcomes[0]?.ok, true) // run completed despite the observer throwing + }) +}) diff --git a/packages/ai-autopilot/src/loop/loop.ts b/packages/ai-autopilot/src/loop/loop.ts new file mode 100644 index 0000000..c11c62a --- /dev/null +++ b/packages/ai-autopilot/src/loop/loop.ts @@ -0,0 +1,179 @@ +import type { DecisionLedger } from '../decisions/ledger.js' +import type { + LoopEvent, + LoopProgress, + LoopPrompt, + LoopRule, + LoopRunResult, + PassResult, + PromptOutcome, +} from './types.js' + +/** Options for {@link Loop}. */ +export interface LoopOptions { + /** The policy: which prompt chains fire for which event kinds. */ + rules: LoopRule[] + /** The prompts a rule can reference, as a list or a map keyed by id. */ + prompts: LoopPrompt[] | Record + /** Consulted by prompts (exposed on {@link LoopContext.ledger}). Optional. */ + ledger?: DecisionLedger + /** + * Failure policy, the sync-vs-async knob: + * - `true` (default) — fire-and-report: every matched prompt runs regardless + * of a prior one failing. + * - `false` — blocking gate: if a prompt's final pass fails, stop the chain + * (a `gate-stop` event); the remaining prompts do not run. The v1 + * approximation of "block until the review passes" (gates on execution + * failure; gating on a review's *verdict* waits for prompts to return one). + */ + continueOnError?: boolean + /** + * Observe progress. Isolated: a throwing callback is logged and swallowed, so + * an observer bug cannot abort a run. + */ + onEvent?: (event: LoopProgress) => void +} + +/** + * The loop engine. Give it a policy ({@link LoopRule}s) and a set of + * {@link LoopPrompt}s; call {@link handle} with a {@link LoopEvent} the agent + * declared and it runs the matching prompt chain (each prompt for its + * fresh-context passes), consulting the decisions ledger when one is set. + * + * ```ts + * const loop = new Loop({ rules: defaultLoopRules(), prompts: [reviewPrompt, ...] }) + * await loop.handle({ kind: 'major-change', summary: 'reworked auth', paths: ['src/auth/*'] }) + * ``` + * + * `handle` awaits the whole chain (the synchronous story); for fire-and-report + * over a stream of events, feed them through {@link watch}, or run `handle` + * inside `launchAutopilot` for a detached background run. + */ +export class Loop { + private readonly rules: LoopRule[] + private readonly prompts: Map + private readonly ledger?: DecisionLedger + private readonly continueOnError: boolean + private readonly emit: (event: LoopProgress) => void + + constructor(opts: LoopOptions) { + if (!Array.isArray(opts?.rules)) throw new TypeError('[ai-autopilot] Loop requires `rules`') + if (opts.prompts == null) throw new TypeError('[ai-autopilot] Loop requires `prompts`') + + this.rules = opts.rules + this.prompts = indexPrompts(opts.prompts) + if (opts.ledger !== undefined) this.ledger = opts.ledger + this.continueOnError = opts.continueOnError ?? true + this.emit = makeEmitter(opts.onEvent) + } + + /** + * The prompt ids that would fire for `event`, in chain order and de-duped + * across all matching rules. Pure — no prompts run. + */ + matches(event: LoopEvent): string[] { + const ids: string[] = [] + const seen = new Set() + for (const rule of this.rules) { + if (!rule.on.includes(event.kind)) continue + for (const id of rule.run) { + if (seen.has(id)) continue + seen.add(id) + ids.push(id) + } + } + return ids + } + + /** Run the prompt chain matching `event`. Resolves when the chain is done. */ + async handle(event: LoopEvent): Promise { + const ids = this.matches(event) + if (ids.length === 0) { + this.emit({ type: 'no-match', event }) + return { event, matched: false, outcomes: [] } + } + this.emit({ type: 'match', event, prompts: ids }) + + const outcomes: PromptOutcome[] = [] + for (const id of ids) { + const prompt = this.prompts.get(id) + if (!prompt) { + this.emit({ type: 'unknown-prompt', promptId: id }) + outcomes.push({ promptId: id, passes: [], ok: false }) + continue + } + + const outcome = await this.runPrompt(prompt, event) + outcomes.push(outcome) + + if (!outcome.ok && !this.continueOnError) { + this.emit({ type: 'gate-stop', promptId: id }) + break + } + } + + this.emit({ type: 'done', event, outcomes }) + return { event, matched: true, outcomes } + } + + /** + * Consume a stream of events, handling each in turn (fire-and-report). Returns + * one {@link LoopRunResult} per event. Sequential so the surface sees an + * ordered narrative; wrap in `launchAutopilot` if you need it detached. + */ + async watch(events: AsyncIterable | Iterable): Promise { + const results: LoopRunResult[] = [] + for await (const event of events as AsyncIterable) { + results.push(await this.handle(event)) + } + return results + } + + private async runPrompt(prompt: LoopPrompt, event: LoopEvent): Promise { + this.emit({ type: 'prompt-start', promptId: prompt.id, passes: prompt.passes }) + const passes: PassResult[] = [] + + // N passes, fresh context each: a new LoopContext per invocation and no + // state carried between them, so the prompt re-derives its answer each time. + for (let pass = 1; pass <= prompt.passes; pass++) { + const ctx = { event, pass, passes: prompt.passes, ...(this.ledger ? { ledger: this.ledger } : {}) } + let result: PassResult + try { + result = { pass, text: await prompt.run(ctx), ok: true } + } catch (error) { + result = { pass, text: '', ok: false, error } + } + passes.push(result) + this.emit({ type: 'pass', promptId: prompt.id, result, passes: prompt.passes }) + } + + const ok = passes[passes.length - 1]?.ok ?? false + this.emit({ type: 'prompt-done', promptId: prompt.id, ok }) + return { promptId: prompt.id, passes, ok } + } +} + +/** Factory mirror of `new Loop(...)`. */ +export function createLoop(opts: LoopOptions): Loop { + return new Loop(opts) +} + +// ─── Internals ─────────────────────────────────────────────────── + +function indexPrompts(prompts: LoopPrompt[] | Record): Map { + const list = Array.isArray(prompts) ? prompts : Object.values(prompts) + const map = new Map() + for (const p of list) map.set(p.id, p) + return map +} + +function makeEmitter(onEvent: LoopOptions['onEvent']): (event: LoopProgress) => void { + if (!onEvent) return () => {} + return (event) => { + try { + onEvent(event) + } catch (err) { + console.error('[ai-autopilot] onEvent callback threw; ignoring:', err) + } + } +} diff --git a/packages/ai-autopilot/src/loop/policy.test.ts b/packages/ai-autopilot/src/loop/policy.test.ts new file mode 100644 index 0000000..de3331e --- /dev/null +++ b/packages/ai-autopilot/src/loop/policy.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { defaultLoopRules, LOOP_EVENTS, LOOP_PROMPTS } from './policy.js' + +describe('defaultLoopRules', () => { + it('maps the two built-in change kinds to their prompt chains', () => { + const rules = defaultLoopRules() + const major = rules.find(r => r.on.includes(LOOP_EVENTS.majorChange)) + const ui = rules.find(r => r.on.includes(LOOP_EVENTS.uiFlow)) + assert.deepEqual(major?.run, [LOOP_PROMPTS.review, LOOP_PROMPTS.codeQuality, LOOP_PROMPTS.security]) + assert.deepEqual(ui?.run, [LOOP_PROMPTS.qa, LOOP_PROMPTS.ux]) + }) + + it('returns fresh arrays each call (safe to extend)', () => { + assert.notEqual(defaultLoopRules(), defaultLoopRules()) + }) +}) diff --git a/packages/ai-autopilot/src/loop/policy.ts b/packages/ai-autopilot/src/loop/policy.ts new file mode 100644 index 0000000..1f42a06 --- /dev/null +++ b/packages/ai-autopilot/src/loop/policy.ts @@ -0,0 +1,44 @@ +import { defineRule } from './define.js' +import type { LoopRule } from './types.js' + +/** + * Canonical event kinds the built-in policy triggers on. The agent declares one + * of these on a {@link LoopEvent} after doing work. + */ +export const LOOP_EVENTS = { + /** A substantial code change: fires review + code-quality + security. */ + majorChange: 'major-change', + /** A new user-facing flow (auth, checkout, ...): fires QA + UX. */ + uiFlow: 'ui-flow', +} as const + +/** + * Canonical prompt ids the built-in policy references. The prompts library + * (#111) registers its bundles under these ids so the default rules resolve; + * the loop itself is prompt-source-agnostic and only knows the ids. + */ +export const LOOP_PROMPTS = { + review: 'review', + codeQuality: 'code-quality', + security: 'security', + qa: 'qa', + ux: 'ux', +} as const + +/** + * The built-in loop policy as data: a major change runs review then code-quality + * then security; a new UI flow runs QA then UX. Extend it by concatenating your + * own {@link defineRule} results, or replace it wholesale. + */ +export function defaultLoopRules(): LoopRule[] { + return [ + defineRule({ + on: LOOP_EVENTS.majorChange, + run: [LOOP_PROMPTS.review, LOOP_PROMPTS.codeQuality, LOOP_PROMPTS.security], + }), + defineRule({ + on: LOOP_EVENTS.uiFlow, + run: [LOOP_PROMPTS.qa, LOOP_PROMPTS.ux], + }), + ] +} diff --git a/packages/ai-autopilot/src/loop/types.ts b/packages/ai-autopilot/src/loop/types.ts new file mode 100644 index 0000000..8069cf7 --- /dev/null +++ b/packages/ai-autopilot/src/loop/types.ts @@ -0,0 +1,120 @@ +import type { DecisionLedger } from '../decisions/ledger.js' + +/** + * "The loop" — the event-to-prompt-chain policy. When the agent does something + * of a given *kind*, the right follow-up prompts fire automatically: a major + * change runs review + code-quality + security; a new UI flow runs QA + UX. + * + * This is the web-app-specific orchestration layer generic harnesses do not + * have. It is semantic (a *kind* of change selects a *set* of prompts), not + * command-driven or run-on-every-PR. The trigger is a {@link LoopEvent} the + * agent declares; a {@link LoopRule} maps its kind to an ordered chain of + * {@link LoopPrompt}s; the {@link Loop} runs them (N fresh-context passes each) + * and can consult the decisions ledger along the way. + */ + +/** + * A semantic trigger the agent declares after doing work — the input to the + * loop. `kind` selects which rules fire (e.g. `major-change`, `ui-flow`); the + * rest is context handed to the prompts that run. + */ +export interface LoopEvent { + /** The semantic change type; matched against {@link LoopRule.on}. */ + kind: string + /** One-line description of what happened, for the prompts that run. */ + summary?: string + /** Files the change touched, so a prompt can scope its work. */ + paths?: readonly string[] + /** Anything else a prompt might use. */ + meta?: Readonly> +} + +/** + * What a {@link LoopPrompt} receives on each pass. `pass` is 1-based; the prompt + * builds a *fresh* context each time it is invoked (see {@link LoopPrompt.run}). + * `ledger`, when the loop has one, lets a prompt consult prior decisions. + */ +export interface LoopContext { + event: LoopEvent + pass: number + passes: number + ledger?: DecisionLedger +} + +/** + * One unit of follow-up work in a chain (review, code-quality, security, ...). + * + * A prompt is *data* plus a `run` thunk. The loop calls `run` once per pass and + * takes its text; running a prompt across a few passes with **fresh context each + * time** improves the result, so `run` is expected to build a new agent/run per + * invocation rather than carry state across passes. + */ +export interface LoopPrompt { + /** Stable id, kebab-case; referenced by {@link LoopRule.run}. */ + readonly id: string + /** Number of fresh-context passes to run. Positive integer; default 1. */ + readonly passes: number + /** Produce this pass's result. Build fresh context each call. */ + run(ctx: LoopContext): string | Promise +} + +/** Author-facing shape for {@link definePrompt}; `passes` defaults to 1. */ +export interface LoopPromptSpec { + id: string + passes?: number + run(ctx: LoopContext): string | Promise +} + +/** + * A policy rule: when an event of one of `on`'s kinds fires, run the prompts in + * `run`, in order (a chain). Multiple rules can match one event; their prompt + * ids are concatenated in rule order and de-duped. + */ +export interface LoopRule { + readonly on: readonly string[] + readonly run: readonly string[] +} + +/** Author-facing shape for {@link defineRule}; `on` may be a single kind. */ +export interface LoopRuleSpec { + on: string | string[] + run: string[] +} + +/** The outcome of one pass of a prompt. */ +export interface PassResult { + pass: number + /** The pass's text; empty when the pass failed. */ + text: string + ok: boolean + /** The failure, when `ok` is false. */ + error?: unknown +} + +/** The outcome of running one prompt (all its passes). */ +export interface PromptOutcome { + promptId: string + passes: PassResult[] + /** True when the final pass succeeded — the result that matters. */ + ok: boolean +} + +/** The full result of handling one {@link LoopEvent}. */ +export interface LoopRunResult { + event: LoopEvent + /** False when no rule matched the event's kind (nothing ran). */ + matched: boolean + /** One entry per dispatched prompt, in chain order. */ + outcomes: PromptOutcome[] +} + +/** Progress events emitted while the loop runs (for logging / a surface). */ +export type LoopProgress = + | { type: 'match'; event: LoopEvent; prompts: string[] } + | { type: 'no-match'; event: LoopEvent } + | { type: 'unknown-prompt'; promptId: string } + | { type: 'prompt-start'; promptId: string; passes: number } + | { type: 'pass'; promptId: string; result: PassResult; passes: number } + | { type: 'prompt-done'; promptId: string; ok: boolean } + | { type: 'gate-stop'; promptId: string } + | { type: 'done'; event: LoopEvent; outcomes: PromptOutcome[] }