Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ai-autopilot-the-loop.md
Original file line number Diff line number Diff line change
@@ -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).
44 changes: 44 additions & 0 deletions packages/ai-autopilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions packages/ai-autopilot/src/loop/define.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
46 changes: 46 additions & 0 deletions packages/ai-autopilot/src/loop/define.ts
Original file line number Diff line number Diff line change
@@ -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]),
})
}
23 changes: 23 additions & 0 deletions packages/ai-autopilot/src/loop/index.ts
Original file line number Diff line number Diff line change
@@ -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'
151 changes: 151 additions & 0 deletions packages/ai-autopilot/src/loop/loop.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
Loading
Loading