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-personas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/ai-autopilot": minor
---

Add the personas layer: stack-aware roles that make autopilot opinionated about the GemStack stack (Vike + universal-orm) instead of generic. `definePersona()` builds a role from a system prompt + skills (composed over `@gemstack/ai-skills`) + tools; `personaAgent()`/`personaWorkers()` materialize personas into Supervisor workers; `personaRoster()` describes them to a planner so plans route to the right role. Ships three built-ins: `vikePageBuilder`, `universalOrmModeler`, and `uiIntentDesigner` (the "declare intent, decouple implementation" UI guardrail). First child (#98) of the ai-autopilot end-to-end epic (#97).
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,17 @@ jobs:
- run: pnpm build
- run: pnpm typecheck
- run: pnpm test

docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# vitepress build fails on dead internal links (config ignores only
# localhost), so this doubles as a link check on every PR.
- run: pnpm --filter @gemstack/docs docs:build
30 changes: 30 additions & 0 deletions packages/ai-autopilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@ Each stage is a plain function, so you mix LLM and deterministic logic freely:
- **`workers`** — a single `Agent` (all subtasks), a `Record<string, Agent>` (routed by `subtask.worker`), or a `WorkerRouter` function.
- **`synthesize`** — a `Synthesizer`: `(task, results) => string`. Defaults to `defaultSynthesize` (concatenate successes, no LLM call); pass `agentSynthesizer(agent)` for an LLM pass.

## Personas — the stack-aware layer

The Supervisor is stack-agnostic. **Personas** add the opinionated knowledge that
makes autopilot know the GemStack stack (Vike + universal-orm) instead of
guessing. A persona is *data*: a name, a one-line role, a system-prompt fragment,
and the skills/tools it brings (composed over [`@gemstack/ai-skills`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-skills)).

Three are built in: `vikePageBuilder` (Vike `+` file conventions, renderer-agnostic),
`universalOrmModeler` (schema-first data, derived migrations), and
`uiIntentDesigner` — the "declare intent, decouple implementation" guardrail that
expresses UI as intent so an AI can't hardcode the wrong markup.

```ts
import { Supervisor, agentPlanner } from '@gemstack/ai-autopilot'
import { stackPersonas, personaWorkers, personaRoster } from '@gemstack/ai-autopilot'

const supervisor = new Supervisor({
// Tell the planner which personas exist so it tags each subtask's `worker`.
plan: agentPlanner(agent(`Decompose the task.\n\n${personaRoster(stackPersonas)}`)),
// Materialize the personas into a worker pool keyed by name.
workers: personaWorkers(stackPersonas, { model: 'anthropic/claude-sonnet-4-5' }),
})

await supervisor.run('Add a paginated orders page backed by an orders table')
```

Define your own with `definePersona({ name, role, systemPrompt, skills?, tools? })`,
or materialize a single persona into an agent with `personaAgent(persona)`. Because
a persona is data, it can be inspected and listed without building an agent first.

## Guardrails

- **`concurrency`** (optional, default 4) — max workers in flight; positive integer.
Expand Down
1 change: 1 addition & 0 deletions packages/ai-autopilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
"dependencies": {
"@gemstack/ai-sdk": "workspace:^",
"@gemstack/ai-skills": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,34 @@
* - {@link Supervisor} — the plan → dispatch → synthesize orchestrator
* - {@link agentPlanner} — turn a planning agent into a {@link Planner}
* - {@link agentSynthesizer} / {@link defaultSynthesize} — combine results
*
* Personas add the stack-aware knowledge layer: reusable roles that know the
* GemStack stack (Vike + universal-orm), materialized into worker agents.
*
* - {@link definePersona} — define a stack-aware role
* - {@link personaAgent} / {@link personaWorkers} — materialize personas for a run
* - {@link personaRoster} — describe personas to a planner
* - {@link stackPersonas} — the built-in Vike + universal-orm personas
*/
export { Supervisor } from './supervisor.js'
export { agentPlanner, type AgentPlannerOptions } from './planner.js'
export { agentSynthesizer, defaultSynthesize } from './synthesizer.js'
export {
definePersona,
PersonaError,
personaInstructions,
personaTools,
personaAgent,
personaWorkers,
personaRoster,
vikePageBuilder,
universalOrmModeler,
uiIntentDesigner,
stackPersonas,
type Persona,
type PersonaSpec,
type PersonaAgentOptions,
} from './personas/index.js'
export type {
Subtask,
PlannedSubtask,
Expand Down
116 changes: 116 additions & 0 deletions packages/ai-autopilot/src/personas/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { z } from 'zod'
import { dynamicTool, type AnyTool } from '@gemstack/ai-sdk'
import type { LoadedSkill } from '@gemstack/ai-skills'
import { definePersona } from './define.js'
import {
personaInstructions,
personaTools,
personaAgent,
personaWorkers,
personaRoster,
} from './compose.js'
import { stackPersonas } from './library.js'

function fakeTool(name: string): AnyTool {
return dynamicTool({ name, description: name, inputSchema: z.object({}) }).server(
async () => 'ok',
) as unknown as AnyTool
}

function fakeSkill(name: string, instructions: string, tools: AnyTool[] = []): LoadedSkill {
return {
manifest: { name, description: name },
instructions,
tools,
resources: [],
}
}

describe('personaInstructions', () => {
it('puts the systemPrompt first, then skill bodies under headers', () => {
const p = definePersona({
name: 'p',
role: 'r',
systemPrompt: 'BASE IDENTITY',
skills: [fakeSkill('refunds', 'HOW TO REFUND')],
})
const out = personaInstructions(p)
assert.ok(out.startsWith('BASE IDENTITY'), 'systemPrompt leads')
assert.match(out, /# Skill: refunds/)
assert.match(out, /HOW TO REFUND/)
})

it('is just the systemPrompt when there are no skills', () => {
const p = definePersona({ name: 'p', role: 'r', systemPrompt: 'ONLY THIS' })
assert.equal(personaInstructions(p), 'ONLY THIS')
})
})

describe('personaTools', () => {
it('unions own tools with skill tools; own tools win a name collision', () => {
const p = definePersona({
name: 'orders',
role: 'r',
systemPrompt: 's',
tools: [fakeTool('lookup')],
skills: [fakeSkill('refunds', '', [fakeTool('lookup'), fakeTool('refund')])],
})
const names = personaTools(p).map(t => t.definition.name)
assert.deepEqual(names.slice(0, 1), ['lookup']) // own tool first, authoritative
assert.ok(names.includes('refund'))
// colliding skill tool is namespaced, not dropped
assert.ok(names.some(n => n.includes('lookup') && n !== 'lookup'))
})
})

describe('personaAgent', () => {
it('materializes an agent carrying the composed instructions and tools', () => {
const p = definePersona({
name: 'p',
role: 'r',
systemPrompt: 'BASE',
tools: [fakeTool('go')],
skills: [fakeSkill('s', 'SKILL BODY')],
})
const a = personaAgent(p, { model: 'anthropic/claude-sonnet-4-5' }) as unknown as {
instructions(): string
tools(): AnyTool[]
model(): string | undefined
}
assert.match(a.instructions(), /BASE/)
assert.match(a.instructions(), /SKILL BODY/)
assert.deepEqual(a.tools().map(t => t.definition.name), ['go'])
assert.equal(a.model(), 'anthropic/claude-sonnet-4-5')
})
})

describe('personaWorkers', () => {
it('keys agents by persona name', () => {
const workers = personaWorkers(stackPersonas)
assert.deepEqual(
Object.keys(workers).sort(),
['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'],
)
})

it('throws on a duplicate persona name', () => {
const p = definePersona({ name: 'dup', role: 'r', systemPrompt: 's' })
assert.throws(() => personaWorkers([p, p]), /duplicate persona name/)
})
})

describe('personaRoster', () => {
it('lists each persona name + role for a planner to route on', () => {
const roster = personaRoster(stackPersonas)
assert.match(roster, /`vike-page-builder`/)
assert.match(roster, /`universal-orm-modeler`/)
assert.match(roster, /`ui-intent-designer`/)
assert.match(roster, /worker/)
})

it('handles an empty list', () => {
assert.match(personaRoster([]), /No personas/)
})
})
79 changes: 79 additions & 0 deletions packages/ai-autopilot/src/personas/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { agent } from '@gemstack/ai-sdk'
import type { Agent, AnyTool } from '@gemstack/ai-sdk'
import { composeInstructions, composeTools, composeMiddleware } from '@gemstack/ai-skills'
import type { Persona } from './types.js'

/**
* The full instructions a persona contributes: its `systemPrompt` first
* (authoritative), then each skill's body under a `# Skill:` header. Reuses the
* `@gemstack/ai-skills` composition so a persona layers skills exactly like a
* `SkillfulAgent` does.
*/
export function personaInstructions(persona: Persona): string {
return composeInstructions(persona.systemPrompt, [...persona.skills])
}

/**
* The persona's own tools unioned with its skills' tools. The persona's own
* tools win a name collision; a colliding skill tool is namespaced, never
* dropped (see `composeTools`).
*/
export function personaTools(persona: Persona): AnyTool[] {
return composeTools([...persona.tools], [...persona.skills])
}

/** Options for materializing a persona into a runnable agent. */
export interface PersonaAgentOptions {
/** Model string (e.g. `anthropic/claude-sonnet-4-5`). Omit to use the agent default. */
model?: string
}

/**
* Materialize a persona into a runnable `ai-sdk` {@link Agent}: its composed
* instructions, tools, and any skill middleware. Kept separate from the persona
* data so the same persona can be inspected or listed without building an agent.
*/
export function personaAgent(persona: Persona, opts: PersonaAgentOptions = {}): Agent {
return agent({
instructions: personaInstructions(persona),
tools: personaTools(persona),
middleware: composeMiddleware([], [...persona.skills]),
model: opts.model,
})
}

/**
* Build a `Record<string, Agent>` keyed by persona name, ready to drop into
* `Supervisor`'s `workers` option so a plan's `subtask.worker` routes to the
* right persona.
*
* @throws if two personas share a name (the key would collide silently).
*/
export function personaWorkers(
personas: readonly Persona[],
opts: PersonaAgentOptions = {},
): Record<string, Agent> {
const workers: Record<string, Agent> = {}
for (const persona of personas) {
if (persona.name in workers) {
throw new Error(`[ai-autopilot] duplicate persona name in workers: "${persona.name}"`)
}
workers[persona.name] = personaAgent(persona, opts)
}
return workers
}

/**
* A planner-facing prompt fragment listing the available personas by name and
* role. Inject it into a planning agent's instructions so it decomposes a task
* into subtasks tagged with the `worker` that should run each one — the bridge
* that makes the Supervisor's plan stack-aware.
*/
export function personaRoster(personas: readonly Persona[]): string {
if (personas.length === 0) return 'No personas are available.'
const lines = personas.map(p => `- \`${p.name}\` — ${p.role}`)
return [
'Available personas (route each subtask to one by setting its `worker` to the persona name):',
...lines,
].join('\n')
}
43 changes: 43 additions & 0 deletions packages/ai-autopilot/src/personas/define.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { definePersona, PersonaError } from './define.js'

describe('definePersona', () => {
it('trims fields and defaults optional arrays to empty', () => {
const p = definePersona({
name: 'my-persona',
role: ' builds things ',
systemPrompt: ' you build things ',
})
assert.equal(p.name, 'my-persona')
assert.equal(p.role, 'builds things')
assert.equal(p.systemPrompt, 'you build things')
assert.deepEqual([...p.skills], [])
assert.deepEqual([...p.tools], [])
assert.deepEqual([...p.appliesTo], [])
})

it('freezes the persona so it cannot be mutated after definition', () => {
const p = definePersona({ name: 'p', role: 'r', systemPrompt: 's' })
assert.throws(() => {
;(p as { name: string }).name = 'other'
})
assert.throws(() => {
;(p.appliesTo as string[]).push('x')
})
})

it('rejects a missing name', () => {
assert.throws(() => definePersona({ name: ' ', role: 'r', systemPrompt: 's' }), PersonaError)
})

it('rejects a non-kebab-case name', () => {
assert.throws(() => definePersona({ name: 'My Persona', role: 'r', systemPrompt: 's' }), PersonaError)
assert.throws(() => definePersona({ name: 'my_persona', role: 'r', systemPrompt: 's' }), PersonaError)
})

it('rejects a missing role or systemPrompt', () => {
assert.throws(() => definePersona({ name: 'p', role: '', systemPrompt: 's' }), PersonaError)
assert.throws(() => definePersona({ name: 'p', role: 'r', systemPrompt: ' ' }), PersonaError)
})
})
36 changes: 36 additions & 0 deletions packages/ai-autopilot/src/personas/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Persona, PersonaSpec } from './types.js'

/** Thrown when a `PersonaSpec` is malformed. Fails fast at definition time. */
export class PersonaError extends Error {
constructor(message: string) {
super(`[ai-autopilot] ${message}`)
this.name = 'PersonaError'
}
}

/**
* Validate a {@link PersonaSpec} and return a frozen {@link Persona}.
*
* Optional fields default to empty arrays so the rest of the library never has
* to null-check them. The result is deep-frozen at the top level: a persona is
* a shared, reusable identity, so mutating one after definition is a bug.
*/
export function definePersona(spec: PersonaSpec): Persona {
const name = spec.name?.trim()
if (!name) throw new PersonaError('persona name is required')
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
throw new PersonaError(`persona name must be kebab-case: ${JSON.stringify(spec.name)}`)
}
if (!spec.role?.trim()) throw new PersonaError(`persona "${name}" needs a role`)
if (!spec.systemPrompt?.trim()) throw new PersonaError(`persona "${name}" needs a systemPrompt`)

const persona: Persona = {
name,
role: spec.role.trim(),
systemPrompt: spec.systemPrompt.trim(),
skills: Object.freeze([...(spec.skills ?? [])]),
tools: Object.freeze([...(spec.tools ?? [])]),
appliesTo: Object.freeze([...(spec.appliesTo ?? [])]),
}
return Object.freeze(persona)
}
Loading
Loading