1 Commits

Author SHA1 Message Date
Zopu Agent
fb7c95b94b Zopu candidate for create-pr-22-94d2ca98-758e-4e23-b733-f2103480f4c2 2026-07-30 03:29:38 +05:30
187 changed files with 5497 additions and 18176 deletions

View File

@@ -1,43 +0,0 @@
---
name: ax-agent-context
description: This skill helps an LLM pick the right AxAgent context tool for a job - contextMap for recurring corpora, contextPolicy presets for within-run trajectory compaction, agent.optimize for offline GEPA instruction/demo tuning, agent.playbook for an evolving context playbook (offline evolve + online update), and recall/memories + skills for per-turn retrieval. Use when the user asks "which context feature should I use", confuses contextMap with contextPolicy or memory, or wants a decision guide for long-context agents. For contextPolicy/contextMap codegen use ax-agent-rlm; for recall/skills use ax-agent-memory-skills; for agent.optimize or agent.playbook use ax-agent-optimize.
version: "23.0.9"
---
# AxAgent Context Selection (@ax-llm/ax)
Use this skill to route a context-management need to the right AxAgent tool, then open the matching codegen skill. AxAgent manages four distinct context objects; choosing the wrong one is the usual mistake. Do not write tutorial prose; pick the tool and hand off.
## Pick The Right Context Tool
| Need | Object | Scope | Use | Next skill |
| --- | --- | --- | --- | --- |
| Many tasks over the same large corpus (repo, doc set, dataset) | Context map | recurring corpus, persists across runs | `contextMap` | `ax-agent-rlm` |
| One long run whose own history must stay under control | Trajectory compaction | this run only | `contextPolicy: { preset, budget }` | `ax-agent-rlm` |
| Evolve task strategy from examples or live feedback | Context playbook | a stage, offline + online | `agent.playbook(...)` | `ax-agent-optimize` |
| Tune the prompt/instructions/demos offline | Instruction text | a program, offline | `agent.optimize(...)` (GEPA) | `ax-agent-optimize` |
| Pull task-relevant facts or guides for a turn | Retrieval | one turn | `recall(...)` / skills | `ax-agent-memory-skills` |
## Defaults
- Recurring corpus + many different questions -> `contextMap` (persistent orientation cache).
- One long multi-turn run with prompt pressure -> `contextPolicy: { preset: 'checkpointed', budget: 'balanced' }`; move to `lean` for very long runs with strong models, `full` for short tasks or weak models.
- Evolve a context playbook -> `agent.playbook(...)` (offline from examples, or online from live feedback).
- Tune instructions/demos offline -> `agent.optimize(...)` (GEPA).
- Fetch facts or guides on demand -> `recall(...)` for memories, `discover({ skills })` for skill guides.
- A single oversized input value (a pasted doc, a big JSON blob) -> do nothing; `autoUpgrade` (ON by default) keeps it runtime-only with a prompt preview. Reach for `contextFields` only when you want a specific inline policy or the value is a large required non-string field. See `ax-agent-rlm`.
## Anti-Patterns
- Do not use `contextMap` to compress a single run's history. That is `contextPolicy`.
- Do not use `contextPolicy` to carry knowledge across runs. That is `contextMap`.
- Do not hand-build a strategy playbook in the prompt. Evolve it with `agent.playbook(...)`.
- Do not stuff a whole corpus into the prompt every run. Use a context map plus on-demand `recall(...)`.
- Do not confuse runtime skills (`discover({ skills })` guides) with these installable codegen skills.
## See Also
- `ax-agent-rlm` - contextPolicy presets, context maps, and runtime sessions.
- `ax-agent-memory-skills` - recall, memories, and dynamic skill loading.
- `ax-agent-optimize` - GEPA via `agent.optimize(...)` and the context playbook via `agent.playbook(...)`.
- `ax-agent` - core agent shape and the final/clarification protocol.

View File

@@ -1,443 +0,0 @@
---
name: ax-agent-memory-skills
description: This skill helps an LLM generate correct AxAgent memory retrieval, context-map, and dynamic skill-loading code using @ax-llm/ax. Use when the user asks about contextMap, AxAgentContextMap, onMemoriesSearch, memoriesCatalog, recall(...), inputs.memories, onLoadedMemories, onUsedMemories, onSkillsSearch, skillsCatalog, AxAgentCatalogSkill, discover({ skills }), onLoadedSkills, onUsedSkills, preloaded skills, preloading memories at forward time, relevanceRanking hints, loaded memory/skill IDs, or carrying memories across forward() calls.
version: "23.0.9"
---
# AxAgent Memory And Skills Rules (@ax-llm/ax)
Use this skill when an agent needs a persistent context map, task-relevant memory retrieval, or skill guides loaded into the executor prompt on demand. For ordinary agent setup use `ax-agent`. For RLM runtime policy use `ax-agent-rlm`. For callbacks and telemetry use `ax-agent-observability`.
## Use These Defaults
- Use a static `skillsCatalog` / `memoriesCatalog` when the skill guides or memories fit in a plain array — Ax then backs `discover({ skills })` / `recall(...)` with a built-in deterministic local search and no host search code is needed.
- Use `onSkillsSearch` / `onMemoriesSearch` when retrieval needs a real backend (vector DB, BM25 service, KV). A host callback always takes precedence over the catalog's built-in search.
- Use `contextMap` when repeated runs inspect the same long external context and should accumulate a small orientation cache automatically.
- `recall(...)` is available to distiller and executor stages when `onMemoriesSearch` or a non-empty `memoriesCatalog` is set.
- `discover({ skills })` is available to the executor when `onSkillsSearch` or a non-empty `skillsCatalog` is set.
- With `skillsCatalog`, the executor prompt also gains a static `### Available Skills` index (id + name + description), so skill discovery is targeted instead of blind.
- Both `recall(...)` and `discover({ skills })` return `void`. The loaded content appears on the next turn.
- Use `onLoadedMemories` / `onLoadedSkills` to observe what got loaded.
- Use `onUsedMemories` / `onUsedSkills` to track what the actor says it actually relied on.
- Child agents do not inherit memory or skills search callbacks; wire them explicitly on every agent that needs the capability.
## Context Map
Use `contextMap` when repeated runs ask different questions over the same long context, document set, or repository. The map is prompt-resident orientation knowledge: structure, concepts, constants, parsing schema, reusable aggregate results, and concrete error patterns. It is not a task-specific answer cache.
Runnable example: [`src/examples/rlm-context-map-live.ts`](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-context-map-live.ts) demonstrates a provider-backed context-map update, `onUpdate` snapshot persistence, finite evolve, and frozen map reuse.
When `contextMap` is configured:
- Ax injects the current map into the distiller prompt.
- Ax updates the map once after each successful completed `forward(...)`.
- By default the map evolves forever. For a finite warmup, create the map with `{ infiniteEvolve: false, evolveSteps: N }`; after `N` successful updates it is still injected but no longer updated.
- Failed runs, aborts, and clarification requests do not update the map.
- Use `onUpdate` to persist `result.map.snapshot()` outside the agent.
```typescript
import { agent, AxAgentContextMap } from '@ax-llm/ax';
const map = new AxAgentContextMap(savedSnapshot, {
maxChars: 4000,
infiniteEvolve: false,
evolveSteps: 10,
});
const myAgent = agent('context:string, query:string -> answer:string', {
contextFields: ['context'],
contextMap: {
map,
onUpdate: ({ map }) => saveSnapshot(map.snapshot()),
},
});
```
Types:
```typescript
type AxAgentContextMapConfig = {
map?: AxAgentContextMap | AxAgentContextMapSnapshot | string;
onUpdate?: (result: AxAgentContextMapUpdateResult) => void | Promise<void>;
};
type AxAgentContextMapOptions = {
maxChars?: number;
infiniteEvolve?: boolean;
evolveSteps?: number;
};
```
## Memory Search
Use `onMemoriesSearch` when the agent needs to pull task-relevant context such as user preferences, prior decisions, project facts, or past conversations from an external store (vector DB, BM25, KV). The actor decides what to load, when, and how much.
When `onMemoriesSearch` is set, the distiller and executor stages gain:
1. An `inputs.memories` field. In JS this is an array of `{ id, content }` entries the actor reads directly. In the prompt, the same entries render as markdown blocks with `ID: \`...\`` lines, matching the Loaded Skills ID style. Each `content` is opaque markdown; frontmatter is not parsed.
2. A `recall(searches: string[]): void` global the actor `await`s to load more entries. Recalled entries are appended to `inputs.memories` and visible from the next turn onward. `recall()` returns nothing.
The responder stage does not receive memories.
### Enabling
```typescript
import { agent } from '@ax-llm/ax';
import type { AxAgentMemoriesSearchFn } from '@ax-llm/ax';
const onMemoriesSearch: AxAgentMemoriesSearchFn = async (
searches,
alreadyLoaded
) => {
// `searches` is the full array passed to recall(...). Batch your
// store lookup in one round-trip.
// `alreadyLoaded` is the current inputs.memories snapshot. Filter
// against it to skip duplicates.
const skip = new Set(alreadyLoaded.map((m) => m.id));
const fresh = await myVectorDB.searchBatch(searches, { topK: 3 });
return fresh.filter((m) => !skip.has(m.id));
};
const myAgent = agent('task:string -> answer:string', {
contextFields: [],
onMemoriesSearch,
});
```
Each memory result must be:
```typescript
type AxAgentMemoryResult = {
id: string;
content: string;
};
```
### Static catalog (no callback)
If the memory set fits in a plain array, skip the callback entirely: pass `memoriesCatalog` and Ax backs `recall(...)` with a built-in deterministic local search (idf-weighted token overlap over `id` + content; not regex, not embeddings). The `alreadyLoaded` contract is preserved — entries already on `inputs.memories` are excluded before ranking.
```typescript
const myAgent = agent('task:string -> answer:string', {
contextFields: [],
memoriesCatalog: [
{ id: 'deploy-window', content: 'Prod deploys only on Tuesday afternoons.' },
{ id: 'user-prefs', content: 'User prefers concise answers.' },
],
});
```
Rules:
- If both `memoriesCatalog` and `onMemoriesSearch` are set, the host callback handles all `recall(...)` searches; the catalog still powers the advisory `relevanceRanking` hint.
- Catalog content is NOT preloaded into the prompt; entries load only when recalled.
- The built-in search is lexical. For semantic retrieval over large stores, supply `onMemoriesSearch` instead.
### Preloading memories at forward time
To seed specific memories for one run (no recall round-trip), pass them as the `memories` input value. They render on `inputs.memories` from the first turn and merge with anything recalled later (deduped by `id`).
```typescript
await myAgent.forward(ai, {
task: 'Plan the deploy',
memories: [{ id: 'deploy-window', content: 'Prod deploys only on Tuesday afternoons.' }],
});
```
### Actor usage
```javascript
// Turn 1: kick off one batched lookup.
await recall(['user preferences', 'project constraints']);
// Turn 2+: matched entries are now visible on inputs.memories.
const prefs = inputs.memories.find((m) => m.id === 'user-prefs-v2');
```
Rules:
- Pass all memory queries in one `await recall([...])` call.
- Do not loop `recall()` calls or wrap them in `Promise.all(...)`.
- Read `inputs.memories` on the next turn to see what landed.
- `recall()` invokes `onMemoriesSearch` with `(searches, alreadyLoaded)` and returns `void`.
- Results land on `inputs.memories` for subsequent turns and render in the prompt as:
```markdown
### Memory
ID: `mem:user-prefs-v2`
...
```
- Entries are deduped by `id` (last-write-wins) and sorted by `id` for prefix-cache stability.
- Memories loaded by the distiller thread automatically to the executor. No second `recall()` is needed for those entries.
- `recall()` may be called multiple times across turns; results accumulate for that run.
- `inputs.memories` lifetime is one `.forward()` call. It resets between calls.
## Carrying Memories Across `.forward()` Calls
To preserve continuity across calls, persist memories in your store and recall them again on the next call. If you want to replay anything loaded on a prior run, observe loads with `onLoadedMemories`.
```typescript
const carried = new Map<string, string>();
const myAgent = agent('task:string -> answer:string', {
contextFields: [],
onMemoriesSearch: async (searches) => {
const fresh = await myVectorDB.searchBatch(searches, { topK: 3 });
const carriedAsResults = [...carried.entries()].map(([id, content]) => ({
id,
content,
}));
return [...carriedAsResults, ...fresh];
},
onLoadedMemories: (results) => {
for (const r of results) carried.set(r.id, r.content);
},
});
```
## Skills Search
Use `onSkillsSearch` when the agent needs to load skill guides such as usage instructions, operational guides, or domain conventions into the executor's system prompt on demand. The actor decides which skills to fetch and when, so you do not pre-render every skill into every prompt.
When `onSkillsSearch` is set, the distiller and executor stages gain:
1. A "Loaded Skills" section in the system prompt that renders matched skill bodies with stable `ID:` values sorted by `id`.
2. A `discover({ skills })` path the actor `await`s to load more skills. Loaded entries appear in the next turn's prompt. `discover(...)` returns nothing.
Skills the distiller loads carry over to the executor automatically. The responder does not see skills.
### Enabling
```typescript
import { agent } from '@ax-llm/ax';
import type { AxAgentSkillsSearchFn } from '@ax-llm/ax';
// Each result is { id?: string; name: string; content: string }.
// If id is omitted, Ax falls back to name.
const onSkillsSearch: AxAgentSkillsSearchFn = async (searches) => {
return mySkillStore.resolveBatch(searches, {
// Recommended backend order: exact id, exact name, then broader search.
// This lets the actor pass one simple string and keeps lookup policy host-side.
strategy: ['id', 'name', 'search'],
topK: 2,
});
};
const myAgent = agent('task:string -> answer:string', {
contextFields: [],
onSkillsSearch,
});
```
Each skill result is:
```typescript
type AxAgentSkillResult = {
id?: string;
name: string;
content: string;
};
```
### Static catalog (no callback)
If the skill set fits in a plain array, skip the callback entirely: pass `skillsCatalog` and Ax backs `discover({ skills })` with a built-in deterministic local search (idf-weighted token overlap over `id` + `name`×2 + `description`×2 + the first 600 chars of `content`; not regex, not embeddings). The executor prompt also gains a static, cache-stable `### Available Skills` index (id + name + description, sorted by id), so the actor searches by known ids instead of guessing.
```typescript
import type { AxAgentCatalogSkill } from '@ax-llm/ax';
const catalog: AxAgentCatalogSkill[] = [
{
id: 'release-checklist',
name: 'Release checklist',
description: 'Steps for shipping a package release safely', // high-signal for matching
content: '1. Bump version\n2. Run tests\n3. Tag and publish',
},
];
const myAgent = agent('task:string -> answer:string', {
contextFields: [],
skillsCatalog: catalog,
});
```
```typescript
type AxAgentCatalogSkill = {
id: string;
name: string;
description?: string;
content: string;
};
```
Rules:
- If both `skillsCatalog` and `onSkillsSearch` are set, the host callback handles all `discover({ skills })` searches; the catalog still powers the `### Available Skills` index and the advisory `relevanceRanking` hint.
- Catalog content is NOT preloaded into the prompt (unlike `skills`); entries load only when matched. Use `skills` for guides that must always be in context, `skillsCatalog` for a larger set loaded on demand.
- The built-in search is lexical. For semantic retrieval over large stores, supply `onSkillsSearch` instead.
### Actor usage
```javascript
// Pass all skill queries in one call.
await discover({ skills: ['release-checklist', 'incident-response'] });
// Next turn: loaded skill bodies render under the "Loaded Skills"
// system-prompt section.
```
Rules:
- `discover({ skills })` invokes `onSkillsSearch` with the raw search strings and returns `void`.
- Resolve each raw string backend-side: prefer an exact `id` match, then an exact `name` match, then fuzzy/full-text search. The actor should not have to choose `id:` vs `name:` syntax.
- Matched skills land under "Loaded Skills" for the next turn.
- Entries are deduped by `id` (last-write-wins) and sorted by `id` for prefix-cache stability.
- If a skill result omits `id`, its trimmed `name` is used as the id for backwards compatibility.
- Skills persist on the agent's `currentSkillsPromptState` across `.forward()` calls, unlike memories.
- Use `agent.getState()` / `setState(...)` to serialize/restore loaded skills.
- `discover({ skills })` may be called multiple times across turns. Within one turn, batch all skill queries in a single call.
- Child agents do not inherit `onSkillsSearch`; wire it explicitly per agent.
## Preloading Skills
If the caller already knows which skills are relevant, pass them up front instead of round-tripping through `discover({ skills })`.
- Init-time: `skills` on `AxAgentOptions` seeds the executor prompt at agent creation. They survive `setState(...)` resets.
- Forward-time: `skills` on `forward(ai, values, { skills })` merge in at the start of that call. Distiller and responder ignore forward-time skills.
Both accept the same shape `onSkillsSearch` returns: `readonly AxAgentSkillResult[]`. Forward-time skills override init-time skills by `id`. `onLoadedSkills` is not fired for preset skills; that callback is for runtime `discover({ skills })` analytics.
```typescript
const releaseAgent = agent('task:string -> answer:string', {
contextFields: [],
skills: [
{
id: 'release-checklist',
name: 'release-checklist',
content: '...',
},
],
});
await releaseAgent.forward(
ai,
{ task: 'Prepare release notes' },
{
skills: [
{
id: 'incident-response',
name: 'incident-response',
content: '...',
},
],
}
);
```
You can use `skills` without setting `onSkillsSearch` at all. That is useful for static guides where the actor never needs to fetch more.
## Advisory Relevance Hints (`relevanceRanking`)
`relevanceRanking` is ON by default — leave it unset; set `relevanceRanking: false` to opt out. The default was flipped after its A/B gate passed (substance-judged, 49 runs per variant per model: small-model first-lookup precision 24%→90% and answer accuracy 14%→29%; frontier-model control accuracy 63%→88% with fewer turns). The generated language ports implement the same advisory hint contract through AxIR Core.
When enabled, a deterministic local ranker scores the agent's discoverable capabilities against the task once per `forward(...)` and injects a short advisory `### Likely Relevant` shortlist into the executor turn — modules (needs `functionDiscovery`), catalog skills (needs `skillsCatalog`), and catalog memories (needs `memoriesCatalog`). The hint is non-authoritative: the full lists still apply and the actor may `discover`/`recall` anything else.
```typescript
const myAgent = agent('task:string -> answer:string', {
contextFields: [],
functionDiscovery: true,
skillsCatalog: catalog,
relevanceRanking: true, // or { topK: 3, minScore: 0.08 }
});
```
Rules:
- Default is ON across TypeScript and generated language ports; domains still self-gate on their prerequisites (`functionDiscovery` for modules, catalogs for skills/memories), so agents without those see no change. Everything else in this skill (catalog search, the Available Skills index) is independent of the flag.
- The shortlist rides a dynamic, non-cached prompt field; the cached system prompt stays byte-stable across tasks.
- On low confidence the ranker emits nothing rather than guessing.
- Memory hint entries include an ~80-char content snippet; very short memories may be usable from the hint alone without a `recall(...)` (such use is not visible to `onUsedMemories`).
- Observe outcomes via the `relevance_ranking` context event (see `ax-agent-observability`).
## Loaded And Used Tracking
`onLoadedMemories` reports what `recall(...)` loaded. `onLoadedSkills` reports what `discover({ skills })` loaded. To track what the actor says it actually relied on, use `onUsedMemories` / `onUsedSkills`.
```typescript
const used: AxAgentUsedMemory[] = [];
await myAgent.forward(
ai,
{ task: 'Make a personal plan' },
{
onUsedMemories: (items) => used.push(...items),
}
);
used; // [{ id, reason, stage }]
```
Rules:
- The actor can only report memory IDs already present in `inputs.memories`.
- The actor can only report skill IDs already present in Loaded Skills.
- Unknown values are dropped.
- When tracking is enabled, the actor sees `await used(id, reason?)`; this is the actor-side declaration mechanism.
- `used(...)` resolves against loaded memory IDs and loaded skill IDs.
- If memory IDs and skill IDs can collide, namespace them in your application, for example `mem:abc` and `skill:planning`.
- Python, Go, and Java accept these observers directly in their agent option maps. C++ wraps them with `register_agent_observer(...)`; Rust wraps them with `agent_observer(...)`. The returned marker can be used in constructor or forward option maps, and observer failures are ignored in every language.
Types:
```typescript
onMemoriesSearch?: AxAgentMemoriesSearchFn;
onLoadedMemories?: (
results: readonly AxAgentMemoryResult[]
) => void | Promise<void>;
onUsedMemories?: (
usedMemories: readonly AxAgentUsedMemory[]
) => void | Promise<void>;
onSkillsSearch?: AxAgentSkillsSearchFn;
onLoadedSkills?: (
results: readonly AxAgentSkillResult[]
) => void | Promise<void>;
onUsedSkills?: (
usedSkills: readonly AxAgentUsedSkill[]
) => void | Promise<void>;
contextMap?: AxAgentContextMapConfig;
skills?: readonly AxAgentSkillResult[];
skillsCatalog?: readonly AxAgentCatalogSkill[];
memoriesCatalog?: readonly AxAgentMemoryResult[];
relevanceRanking?: boolean | { topK?: number; minScore?: number };
```
## Persisting Agent State Across Languages
TypeScript uses `getState()` / `setState()` for the actor runtime snapshot. The generated packages keep their legacy `GetState` / `SetState` (or language-shaped equivalents) as bare-runtime compatibility methods. Use `ExportRuntimeState` / `RestoreRuntimeState` in generated packages when you need the complete portable agent snapshot, including loaded skills and constructor-preset reapplication after restore. Do not interchange the two snapshot shapes.
## Examples
Fetch this for full working code:
- [RLM Memories and Skills](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-memories-and-skills.ts) - `onMemoriesSearch` + `recall()` and `onSkillsSearch` + `discover({ skills })` with load observability and actual usage tracking via `onUsedMemories` / `onUsedSkills`
- [Skills + Memory Ops Assistant](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/skills-and-memory-assistant.ts) - an on-call assistant that recalls past decisions from a memory store and loads the right runbook skill on demand (also ported to Python, Go, Rust, Java, and C++ under `src/examples/<lang>/long-agents/`). All six languages support the native `onMemoriesSearch` / `onSkillsSearch` host callbacks, passed in the agent options at construction (Go/Java use native function values, Rust a `agent_with_search_callbacks` constructor, C++ a `register_*_search` helper); a static `memory_search_results` / `skill_search_results` config is also available.
## Do Not Generate
- Do not assign the result of `await recall(...)` or `await discover(...)`; both return `void`.
- Do not call `recall()` from the responder stage.
- Do not call `discover({ skills })` from the responder stage.
- Do not loop `recall()` calls or wrap them in `Promise.all(...)`.
- Do not loop `discover()` calls or wrap them in `Promise.all(...)`.
- Do not assume child agents inherit `onMemoriesSearch` or `onSkillsSearch`.
- Do not pass `onMemoriesSearch` results via shared fields as a workaround; use `recall(...)`.
- Do not assume `inputs.memories` persists across `.forward()` calls.
- Do not use `onLoadedMemories` / `onLoadedSkills` as proof that the actor relied on an item; use `onUsedMemories` / `onUsedSkills` for actual-use tracking.
- Do not write an `onSkillsSearch` / `onMemoriesSearch` callback that just scans a static array; pass the array as `skillsCatalog` / `memoriesCatalog` instead.
- Do not rely on the built-in catalog search for semantic matching over large stores; it is lexical token overlap — supply a host callback for embeddings/vector search.
- Do not confuse `skills` (always preloaded into the prompt) with `skillsCatalog` (searchable, loaded on demand).

View File

@@ -1,398 +0,0 @@
---
name: ax-agent-observability
description: This skill helps an LLM generate correct AxAgent observability code using @ax-llm/ax. Use when the user asks about axGlobals.onUsage, usageContext, centralized or multi-tenant usage accounting, actorTurnCallback, onContextEvent, agentStatusCallback, onFunctionCall, reportSuccess, reportFailure, getChatLog(), getUsage(), resetUsage(), debug traces, progress updates, or telemetry for AxAgent runs.
version: "23.0.9"
---
# AxAgent Observability Rules (@ax-llm/ax)
Use this skill when an agent needs runtime visibility, progress reporting, tracing, usage accounting, or chat-log access. For ordinary agent setup use `ax-agent`. For RLM runtime policy use `ax-agent-rlm`. For memories and dynamic skill loading use `ax-agent-memory-skills`.
## Choose The Smallest Hook
- Need a quick prompt/runtime trace during development -> start with `debug: true`.
- Need structured per-turn code, raw runtime result, formatted output, provider thoughts, or actor stage -> use `actorTurnCallback`.
- Need context-pressure and compaction telemetry -> use `onContextEvent`.
- Need real-time task progress emitted by actor code -> use `agentStatusCallback`.
- Need every runtime function call before execution -> use `onFunctionCall`.
- Need model prompts/responses after a run -> use `getChatLog()`.
- Need centralized chat/embed usage across APIs, users, agents, and services -> use `axGlobals.onUsage` plus `usageContext`.
- Need token usage by actor/responder -> use `getUsage()` and `resetUsage()`.
- Need usage split by context and task stages -> use `getStagedUsage()`.
- Need Ax program traces -> use `getTraces()`.
- Do not add multiple hooks unless the user clearly needs each output stream.
## Global Runtime Defaults
OpenTelemetry and debug defaults come from the shared Ax runtime surface:
```typescript
import { axGlobals, axCreateDefaultColorLogger } from '@ax-llm/ax';
import { trace } from '@opentelemetry/api';
axGlobals.tracer = trace.getTracer('agent-app');
axGlobals.debug = true;
axGlobals.logger = axCreateDefaultColorLogger();
axGlobals.onUsage = (event) => usageQueue.enqueue(event);
```
These globals are live defaults for future AI, AxGen, AxFlow, and agent-internal model calls. Per-call or explicitly configured options still override `axGlobals`. Use AxAgent callbacks below when the caller needs structured agent-turn events rather than OpenTelemetry spans or debug logs.
## Centralized Usage Observer
Use the process-wide usage observer for application accounting across many agents, API routes, tenants, and users. Keep `getUsage()` for inspecting one agent instance after a run.
```typescript
import { axGlobals } from '@ax-llm/ax';
axGlobals.onUsage = (event) => {
usageQueue.enqueue(event); // Must return immediately.
};
await supportAgent.forward(
llm,
{ query: request.body.query },
{
usageContext: {
tenantId: auth.tenantId,
userId: auth.userId,
requestId: request.id,
runId: crypto.randomUUID(),
feature: 'support-chat',
attributes: { environment: 'production' },
},
}
);
```
Rules:
- The observer receives one immutable normalized event for each completed chat or embedding call that reports provider usage. A fully consumed stream emits once; an unconsumed or cancelled stream may not emit.
- Events include `operation`, `ai`, `model`, normalized `tokens`, `streaming`, optional `context`, and available session or remote request IDs.
- Put stable attribution such as application or environment in AI service-level `usageContext`. Put tenant, user, request, run, and feature attribution in per-call or per-forward `usageContext`.
- Per-call context overrides service defaults. `attributes` are shallow-merged.
- The observer is best-effort and fail-open. Ax does not await it and ignores observer failures, so synchronously enqueue and persist or aggregate out of band.
- The registration is process-wide. A new assignment replaces the previous observer; set `axGlobals.onUsage = undefined` during test teardown or shutdown when appropriate.
- In multi-process or serverless deployments, send events to a shared durable pipeline. Do not treat an in-memory total as application-wide accounting.
- Keep identifiers opaque and attributes low-cardinality. Avoid prompts, responses, secrets, and other sensitive payloads.
- Token events do not estimate currency cost. Apply a versioned provider/model pricing table downstream.
For direct AI calls and the complete event shape, also read `ax-ai`.
## Actor Turn Callback
Use `actorTurnCallback` when the caller needs structured telemetry for each actor turn.
What it gives you:
- `code`: the normalized JavaScript code the actor produced
- `stage`: which actor produced the turn (`distiller` or `executor`)
- `result`: the raw untruncated runtime return value from executing that code
- `output`: the formatted action-log output string after Ax normalizes and truncates it for prompt replay
- `thought`: the actor model's `thought` field when `showThoughts` is enabled and the provider returns one
- `executorResult`: the full actor payload returned by the current actor stage, kept under this historical field name for compatibility
- `isError`: whether the execution path for that turn was treated as an error
- `usage`: token usage for this actor turn only
- `model`: model used for this turn when explicitly set through `executorModelPolicy`
- `chatLogMessages`: raw ChatML conversation for this turn, populated only when an actor turn callback is set
Use it for:
- debug UIs that want to show code plus raw runtime results
- tracing and analytics
- capturing `thought` for internal diagnostics when supported by the provider
- storing per-turn execution artifacts without scraping the prompt/action log
Important:
- `output` is not raw stdout; it is the formatted replay string used in the action log.
- `result` is the raw runtime result before Ax applies type-aware serialization and budget-proportional truncation.
- `thought` is optional and only appears when the underlying `AxGen` call had `showThoughts` enabled and the provider actually returned a thought field.
- `actionLogEntryCount` and `guidanceLogEntryCount` reflect the live log sizes after the turn is processed, including resumed runs.
- `actorTurnCallback` fires for the configured agent instance. Child agents passed through `functions: [...]` should define their own callback if you need their internal actor turns; use `onFunctionCall` on the parent to observe the parent-side child-agent invocation.
Good pattern:
```typescript
const supportAgent = agent('query:string -> answer:string', {
contextFields: ['query'],
runtime,
actorTurnCallback: ({
stage,
turn,
actionLogEntryCount,
guidanceLogEntryCount,
code,
result,
output,
thought,
isError,
usage,
model,
}) => {
console.log({
turn,
stage,
model,
actionLogEntryCount,
guidanceLogEntryCount,
isError,
code,
rawResult: result,
replayOutput: output,
thought,
usage,
});
},
executorOptions: {
model: 'gpt-5.4-mini',
showThoughts: true,
},
});
```
Callback type:
```typescript
actorTurnCallback?: (turn: {
stage: 'distiller' | 'executor';
turn: number;
actionLogEntryCount: number;
guidanceLogEntryCount: number;
executorResult: Record<string, unknown>;
code: string;
result: unknown;
output: string;
isError: boolean;
thought?: string;
usage?: AxProgramUsage[];
model?: string;
chatLogMessages?: ReadonlyArray<{ role: string; content: string }>;
}) => void | Promise<void>;
actorTurnCallback?: (turn: {
stage: 'distiller' | 'executor';
turn: number;
actionLogEntryCount: number;
guidanceLogEntryCount: number;
executorResult: Record<string, unknown>;
code: string;
result: unknown;
output: string;
isError: boolean;
thought?: string;
usage?: AxProgramUsage[];
model?: string;
chatLogMessages?: ReadonlyArray<{ role: string; content: string }>;
}) => void | Promise<void>; // deprecated alias
```
## Context Event Observability
Use `onContextEvent` when the caller needs structured telemetry about prompt pressure and compaction. It does not change model behavior directly; it is for logs, evals, and dashboards.
Events:
- `budget_check`: character-based prompt pressure before an actor turn, with detailed metrics kept out of the actor prompt
- `checkpoint_created` / `checkpoint_cleared`: checkpoint lifecycle events with covered turns and reason
- `tombstone_created`: compact resolved-error summary creation
- `relevance_ranking`: emitted once per ranked domain per forward when `relevanceRanking` is enabled; carries `domain` (`'modules' | 'skills' | 'memories'`), the `shortlist` (`{ id, score }[]`, most relevant first), and `suppressed` (true when the low-confidence guard emitted no hint)
- `field_auto_promoted`: emitted once per field per run when `autoUpgrade` keeps an oversized undeclared input value runtime-only; carries `fieldName`, `originalChars`, and `promptPreviewChars` (undefined when no inline preview was kept)
To measure whether the advisory hint helps, join per forward: `relevance_ranking.shortlist` ids against what the actor then loaded — for modules the internal `discover` calls (`onFunctionCall` with `kind: 'internal'`, `name: 'discover'`, `args.request`) plus the module part of external `qualifiedName`s; for skills `onLoadedSkills` / `used(id)`; for memories `onLoadedMemories` / `used(id)`.
Rules:
- `contextPressure` in the actor prompt is intentionally compact (`ok`, `watch`, `critical` plus one short instruction).
- Budget metrics are character-based for provider neutrality and are exposed through `onContextEvent`, not the actor prompt.
- Callback errors are swallowed so telemetry cannot break the agent run.
- Do not scrape actor prompts for pressure metrics.
```typescript
const supportAgent = agent('query:string -> answer:string', {
contextFields: ['query'],
runtime,
contextPolicy: { preset: 'checkpointed', budget: 'balanced' },
onContextEvent: (event) => {
if (event.kind === 'budget_check') {
console.log(event.pressure, event.mutablePromptChars);
}
},
});
```
Type:
```typescript
onContextEvent?: (event: AxAgentContextEvent) => void | Promise<void>;
```
## Agent Status Callback
Use `agentStatusCallback` when the caller wants real-time progress updates from the actor. When set, the actor can call `await reportSuccess(message)` and `await reportFailure(message)` in its JavaScript turns.
```typescript
const supportAgent = agent('query:string -> answer:string', {
contextFields: ['query'],
runtime,
agentStatusCallback: (message, status) => {
console.log(`[${status}] ${message}`);
},
});
```
Rules:
- `agentStatusCallback` receives `(message: string, status: 'success' | 'failed')`.
- When set, the actor prompt automatically includes `reportSuccess(message)` and `reportFailure(message)` as available runtime functions.
- The actor is instructed to keep the user updated on task progress.
- `reportSuccess` and `reportFailure` are reserved runtime names when the callback is configured.
- Child agents inherit the callback via the RLM config.
Type:
```typescript
agentStatusCallback?: (
message: string,
status: 'success' | 'failed'
) => void | Promise<void>;
```
## On Function Call
Use `onFunctionCall` when the caller wants to observe every function call the actor makes from the JS runtime. It fires before the underlying function runs.
```typescript
const supportAgent = agent('query:string -> answer:string', {
contextFields: ['query'],
runtime,
functions: [helperAgent, lookupOrderTool],
onFunctionCall: ({ name, qualifiedName, args, kind }) => {
console.log(`[${kind}] ${qualifiedName}`, args);
},
});
```
Rules:
- Receives `{ name, qualifiedName, args, kind }`.
- `name` is the bare function name, e.g. `'lookupOrder'`.
- `qualifiedName` is the namespaced name as the actor sees it, e.g. `'tools.lookupOrder'`; for un-namespaced runtime globals it equals `name`.
- `args` is the resolved positional/named arguments object (`Record<string, unknown>`).
- `kind` is `'external'` for caller-registered `functions`.
- `kind` is `'internal'` for agent-injected globals: child agents, `discover`, `recall`, and `used`.
- Fires once per call, before the function executes.
- Errors thrown inside the callback are swallowed so they cannot break the actor loop.
- This is independent from the DSP-layer `onFunctionCall` on `AxProgramForwardOptions`; that hook is for LLM tool-calls and never fires under AxAgent because AxAgent injects functions as runtime globals.
Type:
```typescript
onFunctionCall?: (call: {
name: string;
qualifiedName: string;
args: Record<string, unknown>;
kind: 'internal' | 'external';
}) => void | Promise<void>;
```
## Chat Log, Usage, And Traces
`AxAgent` exposes actor and responder sub-programs. `getChatLog()` returns the same flat `AxChatLogEntry[]` shape as `AxGen` and `AxFlow`; use each entry's optional `name` field to distinguish `distiller`, `executor`, and `responder`. `getUsage()` returns token usage split by actor/responder.
### getChatLog()
Returns the full normalized chat history after any `.forward()` call. Each entry is one `ai.chat()` round-trip. Actor stages accumulate one entry per turn; the responder typically has one entry.
```typescript
const log = myAgent.getChatLog();
for (const entry of log) {
console.log(entry.name, entry.model);
for (const msg of entry.messages) {
console.log(`[${msg.role}]`, msg.content);
}
}
```
Each `AxChatLogEntry` captures the full prompt sent to the model and its response:
```typescript
type AxChatLogMessage =
| { role: 'system'; content: string }
| { role: 'user'; content: string }
| { role: 'assistant'; content: string }
| { role: 'tool'; name: string; content: string };
type AxChatLogEntry = {
name?: string; // e.g. "distiller", "executor", "responder"
model: string;
messages: AxChatLogMessage[];
modelUsage?: AxProgramUsage;
stage?: 'ctx' | 'task';
};
```
### getUsage()
Returns token usage split by actor/responder. Each sub-array contains one `AxProgramUsage` entry per model/run, merged by `(ai, model)` key.
```typescript
const usage = myAgent.getUsage();
// { actor: AxProgramUsage[], responder: AxProgramUsage[] }
console.log('Actor tokens:', usage.actor[0]?.tokens);
console.log('Responder tokens:', usage.responder[0]?.tokens);
```
### getStagedUsage()
Returns usage split by pipeline stage. The `ctx` stage has the distiller actor only; the `task` stage has the executor actor plus responder.
```typescript
const staged = myAgent.getStagedUsage();
console.log(staged.ctx?.actor);
console.log(staged.task.actor);
console.log(staged.task.responder);
```
### getTraces()
Returns Ax program traces for the agent pipeline. Use it when the caller needs trace data rather than chat messages or token summaries.
```typescript
const traces = myAgent.getTraces();
```
### resetUsage()
Resets both actor and responder usage at once:
```typescript
myAgent.resetUsage();
```
Type signatures:
```typescript
// AxAgent
agent.getChatLog(): readonly AxChatLogEntry[]
agent.getUsage(): { actor: AxProgramUsage[]; responder: AxProgramUsage[] }
agent.getStagedUsage(): { ctx?: AxAgentUsage; task: AxAgentUsage }
agent.getTraces(): AxProgramTrace[]
agent.resetUsage(): void
// AxGen / AxFlow
gen.getChatLog(): readonly AxChatLogEntry[]
gen.getUsage(): AxProgramUsage[]
```
## Do Not Generate
- Do not add both `debug: true` and `actorTurnCallback` unless the user wants both unstructured prompt/runtime visibility and structured telemetry.
- Do not scrape actor prompts or action logs when a callback exposes the data directly.
- Do not let observability callback failures break the agent run; Ax swallows callback errors for telemetry hooks.
- Do not use DSP-layer `onFunctionCall` when the user wants AxAgent runtime function calls.
- Do not enable `showThoughts` unless the user needs provider thought diagnostics and the provider supports it.
- Do not use `getUsage()` as the centralized source of truth across shared agents or processes.
- Do not perform database or network work inline in `axGlobals.onUsage`; enqueue and return.

View File

@@ -1,368 +0,0 @@
---
name: ax-agent-optimize
description: This skill helps an LLM generate correct AxAgent tuning and evaluation code using @ax-llm/ax. Use when the user asks about agent.optimize(...), judgeOptions, eval datasets, optimization targets, saved optimizedProgram artifacts, or agent optimization guidance.
version: "23.0.9"
---
# AxAgent Optimize Codegen Rules (@ax-llm/ax)
Use this skill for `agent.optimize(...)` workflows. Prefer short, modern, copyable patterns. Do not repeat general agent-authoring guidance unless the user needs it. For generic `ax(...)` or `flow(...)` tuning with top-level `optimize(...)`, use the `ax-gepa` skill instead.
Your job is to help the model choose a good optimization setup for the user's actual goal:
- If the user wants better tool use, prefer action-aware tasks and either a deterministic metric or the built-in judge depending on how objective the scoring is.
- If the user wants better wording only, responder optimization may be enough.
- If the user wants reusable improvements, include artifact save/load.
- If the user wants cost, tool-use, or child-agent delegation behavior improved, make the eval tasks expose those tradeoffs explicitly.
## Use These Defaults
- Use `agent.optimize(...)` only after the agent is already configured and runnable.
- Prefer the built-in judge path first for normal agent tuning. Most users should start with tasks that include `input` and `criteria`, then let `agent.optimize(...)` use its default actor target and judge-based metric.
- Keep top-level `optimize(program, train, metric, options)` for non-agent generators and flows; do not rewrite normal agent task-record examples to the generic helper.
- Prefer a deterministic custom `metric` only when success is easy to score from the prediction and task record.
- Add `judgeAI` plus `judgeOptions` when the judge should run on a stronger or separate model than the agent runtime model.
- Only reach for a plain typed `AxGen` evaluator when the user needs LLM-as-judge behavior outside the built-in `agent.optimize(...)` flow.
- Default optimize target is the actor path; do not surface `target` unless the user clearly wants responder-only tuning or explicit program IDs.
- Use eval-safe tools or in-memory mocks because optimization replays tasks many times.
- Prefer precise tool return schemas such as `f.object(...)` over vague `f.json(...)` whenever the agent must reason about returned fields.
- Prefer task wording with canonical entity names like "the Atlas project" instead of ambiguous labels like "Atlas" when ambiguity could trigger pointless clarification.
- Save artifacts with `axSerializeOptimizedProgram(result.optimizedProgram!)`, then restore with `axDeserializeOptimizedProgram(saved)` and `agent.applyOptimization(...)`.
- For browser-safe persistence, let the caller store the serialized JSON anywhere they want such as localStorage, IndexedDB, or a backend.
- If `bootstrap` is enabled, bootstrapped demos are persisted inside `result.optimizedProgram.demos`; raw failed traces are not saved in v1.
- Auto-promoted context fields (large undeclared inputs kept runtime-only by `autoUpgrade`) appear in captured traces/demos as their truncated preview string, not the full value — same as declared truncate-style `contextFields`. This is expected; do not treat the shortened value as a bug in the saved demos.
- For first examples, pass a plain task array instead of splitting into `train` and `validation` unless the user already has a holdout set.
- GEPA-backed `agent.optimize(...)` now optimizes generic components exposed by the selected target programs; `target: 'actor'` only tunes actor components, `target: 'responder'` only tunes responder components, and `target: 'all'` broadens the component set.
- `result.optimizedProgram.componentMap` is the canonical saved artifact for agent GEPA runs. It may include actor instructions, descriptions, tool descriptions/names, templates, or runtime primitives depending on what the selected target exposes.
- When child-agent delegation matters, expose the child agents as named functions and tune against realistic call/no-call tasks.
## Decision Guide
Pick the optimization shape from the user's need:
- "Make the agent use tools correctly" -> keep the default actor target and use `expectedActions` and `forbiddenActions`.
- "Make final answers read better" -> consider `target: 'responder'`, but only if the task is not mostly tool-selection or clarification behavior.
- "Make the whole agent better" -> use the default actor target first; only broaden target selection when the user clearly wants that extra scope.
- "Tune child-agent delegation" -> use tasks that exercise when to call the child agent, when to call normal tools, and when to answer directly.
- "Compare before and after" -> include a held-out task plus artifact save/load and replay.
- "Repair the tasks it keeps failing, without eroding what works" -> this is playbook territory, not GEPA: use the agent-bound playbook evolve method (in TypeScript, `agent.playbook().evolve(dataset)`) to mine failures into verified playbook bullets under a held-out gate. Python, Java, C++, Go, and Rust expose the same loop with native method casing; see `ax-playbook`. `optimize(...)` maximizes a metric by tuning instructions and demos; playbook evolution grows durable rules.
Choose task design carefully:
- Prefer a small number of realistic tasks over broad but vague datasets.
- Prefer concrete criteria over generic "be helpful" language.
- Prefer explicit action expectations when correctness depends on tools, recipients, dates, or side effects.
- Prefer eval-safe mocks anytime the task touches email, scheduling, external APIs, or persistence.
## Make Agents Optimizable
Optimization works much better when the agent and dataset remove avoidable ambiguity:
- Prefer typed tool outputs over free-form JSON blobs so the actor can rely on exact field names.
- Tell the actor the exact tool fields it may use when payload shape matters.
- Explicitly ban invented fields if the model has any reason to guess hidden IDs or alternate key names.
- If a child agent needs parent values, declare those fields in the child signature and pass them explicitly at the call site.
- For specialist synthesis, tell the agent what narrowed context should be passed to the child agent.
- Keep `maxSubAgentCalls` small in examples unless the user is explicitly testing broad fan-out behavior.
- Use canonical, unambiguous task wording so the model does not burn turns asking for fake clarification.
- In JS-runtime agents, require raw runnable JavaScript only. Ban `javascript:` prefixes, mixed prose/code, and multi-snippet turns.
Good pattern:
- tool schema says exactly what fields exist
- task names the exact entity to look up
- actor prompt says which fields to extract before calling a child agent
- metric or judge penalizes unnecessary child-agent calls and tool misuse
Bad pattern:
- tool returns `json` with an underspecified shape
- task uses overloaded names like `Atlas` without clarifying whether that is a project, team, or account
- child agent is expected to infer hidden parent state that was never passed in its call arguments
- code agent is allowed to mix natural language with JavaScript in the same turn
## Metric vs Judge
Choose the scoring path based on how objectively the task can be measured:
- Use a custom `metric` when you can score success directly from `prediction` and `example`.
- Use the built-in agent judge when success depends on a full-run qualitative review across tool choices, clarifications, and final output.
- Use `judgeOptions.description` to tell the built-in judge what to value most.
- Use helper-based judge code only when the user is not inside `agent.optimize(...)` and still wants LLM judging.
Quick rules:
- Tool correctness with exact expected calls or forbidden calls: prefer a deterministic metric first.
- Simple extraction or classification with known correct answers: prefer a deterministic metric.
- Open-ended assistant quality, nuanced clarification behavior, or broad synthesis quality: prefer the built-in judge.
- GEPA or optimizer flows outside agents that still need LLM judging: use a plain typed `AxGen` evaluator.
Important:
- A custom `metric` overrides the built-in judge path entirely.
- Do not introduce a dedicated judge abstraction in new examples; prefer a plain typed `AxGen`.
- Do not add both a custom `metric` and judge guidance unless the user explicitly wants two separate scoring systems and understands only the custom metric drives optimization.
- If the user builds a plain `AxGen` judge metric, prefer a numeric `score:number` output over a string tier when possible. It is simpler and less fragile in practice.
## Canonical Pattern
```typescript
import {
AxAIGoogleGeminiModel,
AxJSRuntime,
axDefaultOptimizerLogger,
agent,
ai,
f,
fn,
axDeserializeOptimizedProgram,
axSerializeOptimizedProgram,
} from '@ax-llm/ax';
const tools = [
fn('sendEmail')
.namespace('email')
.description('Send an email message')
.arg('to', f.string('Recipient email address'))
.arg('body', f.string('Email body text'))
.returns(
f.object({
sent: f.boolean('Whether the email was sent'),
to: f.string('Recipient email address'),
})
)
.handler(async ({ to }) => ({ sent: true, to }))
.build(),
];
const studentAI = ai({
name: 'google-gemini',
apiKey: process.env.GOOGLE_APIKEY!,
config: { model: AxAIGoogleGeminiModel.Gemini31FlashLite, temperature: 0.2 },
});
const judgeAI = ai({
name: 'google-gemini',
apiKey: process.env.GOOGLE_APIKEY!,
config: { model: AxAIGoogleGeminiModel.Gemini35Flash, temperature: 1.0 },
});
const assistant = agent('query:string -> answer:string', {
ai: studentAI,
judgeAI,
contextFields: [],
runtime: new AxJSRuntime(),
functions: tools,
contextPolicy: { preset: 'checkpointed', budget: 'balanced' },
judgeOptions: {
description: 'Prefer correct tool use over polished wording.',
model: 'judge-model',
},
});
const tasks = [
{
input: { query: 'Send an email to Jim saying good morning.' },
criteria: 'Use the email tool and send the message to Jim.',
expectedActions: ['email.sendEmail'],
},
];
const result = await assistant.optimize(tasks, {
maxMetricCalls: 12,
verbose: true,
});
const saved = axSerializeOptimizedProgram(result.optimizedProgram!);
const restored = axDeserializeOptimizedProgram(saved);
assistant.applyOptimization(restored);
```
## Minimal Normal-User Pattern
Start here unless the user clearly needs a hand-built scorer:
```typescript
const tasks = [
{
input: { query: 'Send an email to Jim saying good morning.' },
criteria: 'Use the email tool and send the message to Jim.',
expectedActions: ['email.sendEmail'],
},
];
const result = await assistant.optimize(tasks);
assistant.applyOptimization(result.optimizedProgram!);
```
- `target` defaults to actor optimization.
- `metric` defaults to the built-in LLM judge.
- `judgeAI` is optional; if omitted, the agent falls back to its configured judge model or runtime model.
- `bootstrap: true` is a good next step for tool-heavy agents when you want GEPA to start from successful traces from the provided tasks.
- The one thing users still need is realistic task records with clear `criteria`.
## Deterministic Metric Pattern
Use this when the task has crisp correctness and cost/behavior tradeoffs:
```typescript
const result = await assistant.optimize(tasks, {
target: 'actor',
metric: ({ prediction, example }) => {
if (prediction.completionType !== 'final' || !prediction.output) {
return 0;
}
let score = 0;
if (prediction.output.answer.includes('Jim')) score += 0.4;
if (
prediction.functionCalls.some(
(call) => call.qualifiedName === 'email.sendEmail'
)
) {
score += 0.4;
}
if (prediction.turnCount <= 3) {
score += 0.2;
}
return score;
},
});
```
Use this pattern when:
- the task has a known correct answer or exact action pattern
- tool count, child-agent calls, or turn count must be measured explicitly
- you want repeatable, low-variance optimization runs
## Built-In Judge Pattern
Use this when the agent behavior needs holistic review:
```typescript
const result = await assistant.optimize(tasks, {
judgeAI,
judgeOptions: {
model: AxAIGoogleGeminiModel.Gemini35Flash,
description:
'Be strict about unnecessary child-agent calls, weak clarifications, and incorrect tool choices.',
},
maxMetricCalls: 12,
});
```
Use this pattern when:
- task quality is open-ended or hard to score exactly
- the final answer quality matters together with the action trace
- the user wants a judge to consider clarifications, tool errors, and overall completion quality
## Plain `AxGen` Judge Pattern
Use this only when the user needs LLM judging outside the built-in `agent.optimize(...)` path:
```typescript
import { AxGen, s } from '@ax-llm/ax';
const judgeGen = new AxGen(
s(`
taskInput:json "Task input",
candidateOutput:json "Candidate output",
expectedOutput?:json "Optional reference output"
->
score:number "Normalized score from 0 to 1"
`)
);
judgeGen.setInstruction(
'Score the candidate output from 0 to 1. Reward correctness and task completion. Return only the score field.'
);
const metric = async ({ prediction, example }) => {
const result = await judgeGen.forward(judgeAI, {
taskInput: example,
candidateOutput: prediction,
expectedOutput: example.expectedOutput,
});
return Math.max(0, Math.min(1, result.score));
};
const result = await optimizer.compile(program, train, metric, {
validationExamples: validation,
});
```
Use this pattern when:
- the user is optimizing an `AxGen`, flow, or another program directly
- the user wants LLM judging without the higher-level `agent.optimize(...)` wrapper
- the user wants to inspect judge results directly, not just a numeric score
## Dataset And Judge Rules
- Pass already-loaded tasks. Do not invent a benchmark loader unless the user asks for one.
- Use `expectedActions` and `forbiddenActions` when tool correctness matters.
- `judgeOptions` mirrors normal forward options and supports extra judge guidance through `description`.
- The built-in judge scores from the full agent run, not just the final reply. It can see completion type, clarification payload, final output, action log, normalized function calls, tool errors, and turn count.
- If the user provides a custom `metric`, that overrides the built-in judge path.
- If the user provides an LLM-based custom metric, keep the output schema as small as possible and prefer a direct numeric score.
Decision rules:
- Prefer a custom metric when the user has deterministic business scoring, exact action expectations, or explicit cost tradeoffs.
- Prefer the built-in judge when the user wants practical assistant-quality tuning and does not already have a trusted metric.
- Prefer a plain typed `AxGen` evaluator when the user is not calling `agent.optimize(...)` but still wants LLM judging.
- Prefer `judgeOptions.description` to steer the judge toward the user's real priority, such as tool correctness, brevity, groundedness, or policy compliance.
## Eval Semantics
- MCP/UCP evaluation defaults to replay or sandbox mode. A live client is rejected unless `mcpEvaluation: 'live'` is explicit.
- Use `ax-mcp` for recording/replay transport setup and MCP side-effect policy.
- Use `AxMCPRecordingTransport` to capture a real session once and `AxMCPReplayTransport` for deterministic optimization/evaluation.
- Replay normalized MCP notifications and task transitions through
`AxEventRuntime`; do not leave a live subscription active in a default
optimization run.
- Action traces include qualified MCP/UCP operations, approvals, task transitions, raw protocol errors, and business outcomes for judges and deterministic metrics.
- `agent.optimize(...)` runs each evaluation rollout from a clean continuation state.
- Saved runtime state from `getState()` and `setState(...)` is not used during eval rollouts.
- During optimize/eval, `askClarification(...)` is treated as a scored evaluation outcome instead of going through the responder.
- For clarification outcomes in custom metrics, expect `prediction.completionType === 'askClarification'`, populated `prediction.clarification`, and absent `prediction.output`.
- For final outcomes in custom metrics, expect `prediction.completionType === 'final'` and populated `prediction.output`.
- `target: 'responder'` still works, but clarification-heavy tasks are usually low-signal for responder optimization.
## Delegation Optimization Notes
- Prefer explicit child agents in `functions: [...]` for specialist delegation. Their calls appear as normal function-call records.
- When delegation behavior matters, tune against the same child-agent/tool structure you expect in production.
- Tell the actor which fields to pass to the child agent and which tasks should stay local.
- For synthesis-style tasks, specify the desired delegation pattern explicitly, for example "call `team.writer(...)` only after narrowing tool output in JS."
- Penalize unnecessary child-agent calls directly in the metric or judge prompt.
- If one training task keeps collapsing to zero, inspect that task first instead of adding more optimizer rounds. Most failures come from task ambiguity, weak tool schemas, or vague delegation guidance rather than GEPA itself.
## Artifacts And Replay
- Save `result.optimizedProgram` if the user wants portable artifacts.
- Restore artifacts with `new AxOptimizedProgramImpl(...)`, then call `agent.applyOptimization(...)`.
- Preserve the full optimized program when saving GEPA artifacts; `componentMap` reapplies the learned strings.
- For demonstrations, use fresh eval-safe tool state for baseline, optimize, and restored replay so side effects do not leak across phases.
- If the user wants to show improvement, run a held-out task before optimization, then replay it on a freshly restored optimized agent.
## Examples
- [RLM Agent Optimize](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-agent-optimize.ts) — Gemini office-assistant tuning with save/load
- [AxAgent GEPA Component Optimization](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/axagent-gepa-optimization.ts) — compact support-agent GEPA run with deterministic metric and artifact replay
## Do Not Generate
- Do not optimize against production tools with real side effects unless the user explicitly wants that.
- Do not recommend responder-only optimization by default for clarification-heavy workflows.
- Do not omit artifact save/load steps when the user asks for reusable optimized configurations.
- Do not introduce a dedicated judge class or helper abstraction in new agent-optimize examples; prefer the built-in judge path or a plain typed `AxGen`.
- Do not rely on vague `json` tool returns when the agent must reason about specific fields across tool or child-agent calls.
- Do not leave child-agent inputs implicit. If the child needs a fact, pass it explicitly.
- Do not let code-generation agents mix prose and JavaScript if the user is optimizing runtime behavior.

View File

@@ -1,501 +0,0 @@
---
name: ax-agent-rlm
description: This skill helps an LLM generate correct AxAgent RLM/runtime code using @ax-llm/ax. Use when the user asks about RLM code execution, AxJSRuntime, contextFields, contextPolicy, liveRuntimeState, promptLevel, stage prompt controls, executorModelPolicy, maxRuntimeChars, agent.test(...), llmQuery(...), recursionOptions, or long-running agent runtime behavior.
version: "23.0.9"
---
# AxAgent RLM Runtime Rules (@ax-llm/ax)
Use this skill for code-runtime agents and `llmQuery(...)` semantic-helper behavior. For ordinary agent setup, child agents, tool namespaces, clarification, and `bubbleErrors`, use `ax-agent`. For callbacks and logs, use `ax-agent-observability`. For memories and skill loading, use `ax-agent-memory-skills`.
## Use These Defaults
- Use `agent(...)`, not `new AxAgent(...)`.
- In stdout-mode RLM, use one observable `console.log(...)` step per non-final actor turn.
- Rely on `autoUpgrade` (ON by default) for oversized inputs you did not declare in `contextFields`: any input value over ~8k serialized chars is kept runtime-only automatically, with a 1,200-char prompt preview plus a `contextMetadata` line, while the full value stays live in the runtime as `inputs.<field>`. Declare a field in `contextFields` only when you want a specific inline policy (`promptMaxChars` / `keepInPromptChars`) or need a large required non-string field kept out of the prompt (those are left inline by auto-upgrade).
- Default to `contextPolicy: { preset: 'checkpointed', budget: 'balanced' }` for most RLM tasks.
- Prefer `contextPolicy: { preset: 'adaptive', budget: 'balanced' }` when older successful turns should collapse sooner while live runtime state stays visible.
- Use `contextMap` for recurring long-context corpora when the distiller should start future runs with a small persisted orientation cache.
- Prefer `promptLevel: 'default'` for normal use.
- Use `promptLevel: 'detailed'` when you want extra anti-pattern examples and tighter teaching scaffolding in the actor prompt.
- Prefer `executorModelPolicy` when the actor may need to upgrade after repeated error turns or discovery in specific namespaces without also upgrading the responder.
- Use explicit child agents in `functions: [...]` when the task needs specialist agents with their own tools/runtime.
- Use `llmQuery(...)` only for focused semantic questions over narrowed context; it does not spawn a tool-using child AxAgent.
- Prefer `maxSubAgentCalls` only when you need an explicit cap on `llmQuery(...)` sub-query usage.
## Mental Model
`AxAgent` is a three-stage pipeline. Each `forward()` call walks the stages in order:
```text
distiller (RLM actor) -> executor (RLM actor) -> responder (synthesizer)
```
- **distiller** always runs first. It sees all original inputs so it can understand and normalize the task; declared `contextFields` stay runtime-only when present. It distils relevant evidence by writing runtime-language code in a multi-turn loop, then calls the runtime-exposed `final(request, evidence)` primitive. The request becomes the executor's `inputs.executorRequest`; it must be self-contained and restate the concrete action, target, and constraints, not vague wording like "do it". The distiller should expand the original user task with facts found in context, including follow-ups like "yes, do it". When no `contextFields` are configured, it still performs request normalization over the original inputs with `contextFields: []`. **The distiller has no tools and is not a capability gate.**
- **executor** runs unless the distiller skipped it (below). It receives non-context inputs plus `inputs.executorRequest`, a compact `distilledContextSummary` prompt field, and the real evidence live as `inputs.distilledContext` from the distiller's `final(request, evidence)` payload. Declared or auto-promoted context fields stay runtime-readable as `inputs.<field>` when `contextMetadata` lists them, but their raw contents are not pasted into the executor prompt. The executor owns tool use, decides whether to call its available functions or finish directly from distilled evidence, and reports actual tool results or failures.
- **responder** always runs last. It synthesizes the user's output signature from whichever upstream actor finished the run and must not contradict tool evidence gathered upstream.
### Direct respond (executor skip)
With `directResponse: 'auto'` (the default), the distiller can end the run with the `respond(task, evidence)` primitive when the task needs no user-provided functions — the executor stage is skipped entirely (zero executor model calls) and the responder synthesizes straight from the distiller's evidence. Unlike `final`, whose evidence stays live in the shared session by reference, `respond`'s evidence crosses into the responder prompt (budgeted by `maxEvidenceChars`), and the distiller's runtime variables are exported as the cross-run state exactly as the executor's would have been.
- **Static agents** (no `functions`, no child agents) run respond-only: `final` is not offered to the distiller and every run is distiller → responder.
- **Agents with functions** get `respond` alongside `final` under a conservative covenant: only for tasks answered purely by reading/synthesizing provided context, never when a listed function/module domain covers the need, never for current/live/fresh-state asks (context may be stale — tools are the source of truth for "now"), never for side effects. Landing-gate eval (both pinned models, 3 repeats): 0 false skips on tool-required tasks including a stale-context trap, 100% skip recall on pure context Q&A.
- `directResponse: 'off'` removes the primitive from the prompt and the runtime, and the pipeline rejects a respond payload outright.
Treat both actor stages as long-running code runtime sessions that the actor steers over multiple turns, not as fresh script generators on every turn. `AxJSRuntime` is the default; custom runtimes set `language` so the actor code field becomes `<language>Code` such as `pythonCode` while JavaScript keeps the legacy `javascriptCode`.
- Successful code leaves variables, functions, imports, and computed values available in the runtime session.
- The actor should continue from existing runtime state instead of recreating prior work.
- `actionLog`, `liveRuntimeState`, and checkpoint summaries only control what the actor can see again in the prompt.
- Rebuild state only after an explicit runtime restart notice or when you intentionally need to overwrite a value.
## RLM Actor Code Rules
Use these rules when generating actor JavaScript for RLM in `AxJSRuntime` stdout mode. For custom runtimes, follow the runtime's `getUsageInstructions()`, primitive overrides, and callable formatter instead.
- Treat each actor turn as exactly one observable step.
- Inspect what already exists before recomputing it. If a prior turn successfully created a value, prefer reusing that runtime value.
- If you need to inspect a value, compute it or read it, `console.log(...)` it, and stop immediately after that `console.log(...)`.
- On the next turn, continue from the existing runtime state and use the logged result from `Action Log` only as evidence for what happened.
- If the prompt contains `Live Runtime State`, treat it as the canonical view of current variables.
- Errors from child-agent or tool calls appear in `Action Log`; inspect them and fix the code on the next turn.
- Non-final turns should contain exactly one `console.log(...)`.
- Final turns should call `await final(outputGenerationTask, context)` or `await askClarification(...)` without `console.log(...)`.
- Do not write a complete multi-step program in one actor turn.
- Do not combine `console.log(...)` with `await final(...)` or `await askClarification(...)` in the same actor turn.
- Inside actor-authored JavaScript, `await final(...)` and `await askClarification(...)` end the current turn immediately; code after them is dead code.
- Do not re-declare or recompute values just because older turns are summarized; only rebuild after an explicit runtime restart or when you intentionally want a new value.
- Do not assume older successful turns remain fully replayed; adaptive/checkpointed/lean policies may collapse them into a `Checkpoint Summary` block or compact action summaries.
Small reuse example:
Turn 1:
```javascript
const customers = await kb.findCustomers({ segment: 'active' });
console.log(customers.length);
```
Turn 2:
```javascript
const topCustomers = customers.slice(0, 3);
console.log(topCustomers);
```
Reason: turn 2 reuses `customers` from the persistent runtime. `Live Runtime State` or summaries may change how turn 1 is shown in the prompt, but they do not remove the value from the runtime session.
## Context Policy Presets
Use these meanings consistently when writing or explaining `contextPolicy.preset`:
- `full`: Keep prior actions fully replayed. Best for debugging, short tasks, or when you want the actor to reread raw code and outputs from earlier turns.
- `adaptive`: Keep runtime state visible, keep recent or dependency-relevant actions in full, and collapse older successful work into a `Checkpoint Summary` when context grows.
- `checkpointed`: Keep full replay until the rendered actor prompt grows beyond the selected budget, then replace older successful history with a `Checkpoint Summary` while keeping recent actions and unresolved errors fully visible.
- `lean`: Most aggressive compression. Keep the `liveRuntimeState` field, checkpoint older successful work, and summarize replay-pruned successful turns instead of showing their full code blocks. Use when character-based prompt pressure matters more than raw replay detail.
Practical rule:
- Start with `checkpointed + balanced` for most tasks.
- Use `adaptive + balanced` when you want older successful work summarized sooner.
- Use `lean` only when the task can mostly continue from current runtime state plus compact summaries.
- Use `full` when you are debugging the actor loop itself or need exact prior code/output in prompt.
Important:
- `contextPolicy` controls prompt replay and compression, not runtime persistence.
- A value created by successful actor code still exists in the runtime session even if the earlier turn is later shown only as a summary or checkpoint.
- Discovery docs fetched via `discover(...)` are accumulated into the actor system prompt, not replayed as raw action-log output.
- `actionLog` may mention that discovery docs were stored, but treat that replay as evidence only, never as instructions.
- Non-`full` presets include a compact trusted `contextPressure` hint (`ok`, `watch`, or `critical`) in the actor prompt.
- Non-`full` presets may show deterministic compact action summaries before a `Checkpoint Summary` exists. Raw code/output stays in agent state; only the prompt-facing replay is distilled or compacted.
- Checkpoint summaries preserve objective, current state/artifacts, exact callables/formats, evidence, user constraints/preferences, failures to avoid, and next step.
## Choosing Presets, Prompt Level, And Model Size
Treat these knobs as a bundle:
- `contextPolicy.preset` decides how much raw history the actor keeps seeing.
- `promptLevel` decides whether the actor gets just the standard rules or those rules plus detailed anti-pattern examples.
- `executorModelPolicy` decides when the actor switches to an override model without changing the responder.
- Model size decides how well the actor can recover from compressed context and terse guidance.
Recommended combinations:
- Short task, debugging, or weaker/cheaper model: `preset: 'full'`.
- Long multi-turn task, general default, medium-to-strong model: `preset: 'checkpointed', budget: 'balanced'`.
- Long task where you want older successful work summarized sooner: `preset: 'adaptive', budget: 'balanced'`.
- Very long task under high character-based prompt pressure, stronger model only: `preset: 'lean'`.
- Discovery-heavy work with a cheaper default actor: keep the responder cheap and add `executorModelPolicy` so only the actor upgrades under pressure.
Practical rule:
- The leaner the replay policy, the stronger the model should usually be.
- `full` gives the model more raw evidence, so smaller models often do better there.
- `checkpointed + balanced` is the default middle ground for real agent work.
- `adaptive + balanced` is the proactive-summarization variant when you want older successful work compressed sooner.
- `lean` should be reserved for models that can reason well from runtime state plus summaries instead of exact old code/output.
- `executorModelPolicy` is usually better than globally upgrading the whole agent when the bottleneck is actor exploration rather than responder synthesis.
## Option Layout
Use these top-level controls consistently:
- `recursionOptions.ai`: routes `llmQuery(...)` sub-query calls to a different AI service than the parent run.
- `recursionOptions.model`, `modelConfig`, and other forward options: tune the AxGen call used by `llmQuery(...)`.
- `maxSubAgentCalls`: shared `llmQuery(...)` sub-query budget across the whole run. Default is `100`.
- `maxBatchedLlmQueryConcurrency`: caps batched `llmQuery([...])` concurrency.
- `maxRuntimeChars`: runtime/output truncation ceiling for console logs, tool results, and interpreter output replay. The effective limit is computed dynamically each turn based on remaining context budget.
- `summarizerOptions`: default model/options for the internal checkpoint summarizer.
- `contextPolicy`: replay/checkpointing/compression policy.
- `contextMap`: optional persistent orientation cache injected into the distiller and updated once after each successful run. `AxAgentContextMap` evolves indefinitely by default; use `{ infiniteEvolve: false, evolveSteps: N }` on the map object for finite warmup followed by reuse.
- `contextOptions`: distiller-stage forward options.
- `autoUpgrade`: smart defaults, ON by default. Auto-enables `functionDiscovery` for large tool catalogs and keeps oversized undeclared input values runtime-only with a truncated prompt preview. Set `false` to opt out, or tune per side: `{ functionDiscovery?: boolean | { aboveFunctionDocChars }, contextFields?: boolean | { promoteAboveChars, previewChars } }`. Explicit `functionDiscovery` and declared `contextFields` always win.
- `executorOptions`: executor-stage forward options such as `description`, `model`, `modelConfig`, `thinkingTokenBudget`, and `showThoughts`.
- `executorModelPolicy`: executor-only model override rules based on consecutive error turns or discovery fetches from listed namespaces.
- `responderOptions`: responder-stage forward options.
- `judgeOptions`: built-in judge options for `agent.optimize(...)`; for tuning workflows use `ax-agent-optimize`.
Canonical shape:
```typescript
const researchAgent = agent('query:string -> answer:string', {
contextFields: ['query'],
runtime,
recursionOptions: {
model: 'gpt-5.4-mini',
},
maxRuntimeChars: 3000,
summarizerOptions: {
model: 'gpt-5.4-mini',
modelConfig: { temperature: 0.1, maxTokens: 180 },
},
contextPolicy: {
preset: 'checkpointed',
budget: 'balanced',
},
contextOptions: {
model: 'gpt-5.4-mini',
maxTurns: 3,
},
executorOptions: {
description: 'Use tools first and keep JS steps small.',
model: 'gpt-5.4-mini',
},
executorModelPolicy: [
{
model: 'gpt-5.4',
aboveErrorTurns: 2,
namespaces: ['db', 'kb'],
},
],
responderOptions: {
model: 'gpt-5.4-mini',
},
});
```
Semantics:
- `maxRuntimeChars` sets the truncation ceiling and is separate from `contextPolicy.budget`.
- `summarizerOptions` tunes only the internal checkpoint summarizer. It does not change actor or responder model selection.
- `executorModelPolicy` only switches the actor model. It does not change `responderOptions.model`.
- `llmQuery(...)` uses `recursionOptions.ai` when set, otherwise it falls back to the parent `.forward(ai, ...)` service.
- `recursionOptions` configures the AxGen semantic sub-query used by `llmQuery(...)`; it does not create a child AxAgent and cannot give the sub-query tools.
- `executorModelPolicy` entries are ordered from weaker to stronger. If multiple rules match, the last matching entry wins.
- If one entry defines `namespaces`, any successful `discover(...)` function-definition fetch from one of those namespaces marks the rule as matched starting on the next actor turn.
- Do not add `recursionOptions` unless the user needs different model/options for `llmQuery(...)`.
## Dynamic Output Truncation
Runtime output truncation is budget-proportional and type-aware:
- Early turns with little action-log pressure use the full `maxRuntimeChars` ceiling.
- As the action log fills toward `targetPromptChars`, the limit decays linearly down to 15% of the ceiling, hard-floored at 400 chars.
- Large arrays keep the first 3 and last 2 items, with the middle replaced by `... [N hidden items]`.
- Deep objects replace nested values beyond depth 3 with `[Object]` or `[Array(N)]`.
- Error stack traces keep the first 3 and last 1 stack frames.
- Simple values use standard `JSON.stringify` passthrough.
Users do not need to configure this behavior. `maxRuntimeChars` sets the upper bound; the dynamic system only reduces it.
## Stage Prompt Controls
The pipeline has three peer stage-config bags: `contextOptions` (distiller), `executorOptions` (executor), and `responderOptions` (responder). Each accepts the same shape: `description`, `model`, `modelConfig`, `excludeFields`, plus other forward options.
Key fields:
- `contextOptions.description`: append extra distiller-specific instructions.
- `executorOptions.description`: append extra executor-specific instructions; this is the typical place for tool-use guidance.
- `responderOptions.description`: append extra responder-specific instructions.
- `contextOptions.model` / `executorOptions.model` / `responderOptions.model`: split model choice across stages.
- `contextOptions.ai` / `executorOptions.ai` / `responderOptions.ai`: override the AI service for a specific stage.
- `executorModelPolicy`: auto-switch only the executor when the run is on a consecutive error streak or discovery fetches land in specific namespaces.
Good split-model pattern:
```typescript
const researchAgent = agent('query:string -> answer:string', {
contextFields: ['query'],
runtime,
contextPolicy: { preset: 'checkpointed', budget: 'balanced' },
executorOptions: {
model: 'gpt-5.4',
},
responderOptions: {
model: 'gpt-5.4-mini',
},
});
```
Model guidance:
- Put the stronger model on the actor when the task depends on multi-turn exploration, discovery, runtime state reuse, or compressed replay.
- Put the stronger model on the responder only when the hard part is final synthesis/formatting rather than exploration.
- For cost-sensitive setups, a common pattern is stronger actor plus cheaper responder.
- Prefer `executorModelPolicy` over globally upgrading the whole agent when the actor only needs help after context grows or the run starts thrashing.
Prompt/cache shape:
- Actor turns are compact observable turns, not replayed chat transcripts.
- Stable system prompt: role/stage rules, primitive descriptions, static module list, always-included callable signatures, output contract, and field definitions.
- Cached working inputs: task inputs, inline context, `contextMetadata`, `contextMap`, `memories`, `executorRequest`, `distilledContextSummary`, `discoveredToolDocs`, `loadedSkills`, and `summarizedActorLog`.
- Dynamic turn tail: `guidanceLog`, `actionLog`, `liveRuntimeState`, and `contextPressure`.
- Prefer one compact inspection per non-final turn. Never combine inspection output with `final(...)` or `askClarification(...)`.
Invalid actor turn:
```javascript
await discover(['kb.findSnippets']);
const snippets = await kb.findSnippets({ topic: 'severity' });
await final("Summarize severity findings", { snippets });
```
Reason: this mixes observation and follow-up work in one turn. `discover(...)` returns `void`; read the next prompt's "Discovered Tool Docs" section before calling the function.
## AxJSRuntime Security
Default `new AxJSRuntime()` is hardened: no network, no filesystem, no child process, dynamic `import()` blocked, intrinsics frozen, `ShadowRealm` locked to `undefined`, worker IPC locked in browser/Deno/Bun, Bun workers use `smol: true`, and on Node 20+ the OS Permission Model auto-engages where available.
Threat model: this is defense-in-depth for LLM-authored code, not a container or VM boundary. Host callbacks and granted runtime permissions remain the authority boundary; keep durable secrets and privileged effects in host-side functions.
Permission enum (`AxJSRuntimePermission`):
`NETWORK`, `STORAGE`, `CODE_LOADING`, `COMMUNICATION`, `TIMING`, `WORKERS`, `FILESYSTEM`, `CHILD_PROCESS`.
Options quick reference:
- `permissions?: readonly AxJSRuntimePermission[]`: default `[]`; opt in capabilities.
- `blockDynamicImport?: boolean`: default `true`.
- `allowedModules?: readonly string[]`: default `[]`; narrow dynamic-import allowlist gate. Allowlisted specifiers are attempted, but full Node module namespace passthrough depends on Node vm semantics.
- `freezeIntrinsics?: boolean`: default `true`.
- `blockShadowRealm?: boolean`: default `true`.
- `lockWorkerIPC?: boolean`: default `true`.
- `preventGlobalThisExtensions?: boolean`: default `false`; opt-in and breaks top-level persistence.
- `useNodePermissionModel?: boolean | 'auto'`: default `'auto'`.
- `nodePermissionAllowlist?: { fsRead?; fsWrite?; childProcess?; addons?; wasi? }`.
- `resourceLimits?: { maxOldGenerationSizeMb?; maxYoungGenerationSizeMb?; codeRangeSizeMb?; stackSizeMb? }`.
- `allowDenoRemoteImport?: boolean`: default `false`.
- `allowUnsafeNodeHostAccess?: boolean`: default `false`.
Recipes:
```typescript
new AxJSRuntime();
new AxJSRuntime({ permissions: [AxJSRuntimePermission.NETWORK] });
new AxJSRuntime({
permissions: [AxJSRuntimePermission.FILESYSTEM],
allowedModules: ['node:fs', 'node:fs/promises', 'node:path'],
useNodePermissionModel: 'auto',
nodePermissionAllowlist: {
fsRead: ['/app/data'],
fsWrite: ['/app/data'],
},
});
```
Rules for the LLM author:
- Default to `new AxJSRuntime()` with no options unless the user asked for a specific capability.
- When the user asks for `fetch`, add `permissions: [AxJSRuntimePermission.NETWORK]`.
- When the user asks for filesystem access, prefer host-side tool functions. If direct runtime filesystem access is required, add `permissions: [AxJSRuntimePermission.FILESYSTEM]`, scope with `nodePermissionAllowlist` when the user names a directory, and treat `allowedModules` as an import allowlist gate rather than a portability guarantee.
- Do not disable `freezeIntrinsics`, `blockShadowRealm`, or `lockWorkerIPC` unless the user explicitly asks.
- Treat `allowUnsafeNodeHostAccess: true` as a red flag; only use it when the user is authoring trusted code in their own process.
- `preventGlobalThisExtensions: true` breaks top-level `var`/`let`/`const` persistence across turns; never set it for stdout-mode RLM where persistence is load-bearing.
- On Deno, `blockDynamicImport` is a no-op; the defense is the worker permission sandbox. Pass `allowDenoRemoteImport: true` only if remote module loading is genuinely required.
## Custom Code Runtimes
Implement `AxCodeRuntime` when the actor should write a language other than JavaScript.
- Set `language` to the model-facing language name. JavaScript aliases (`JavaScript`, `js`, `ecmascript`) keep `javascriptCode`; other values derive lower-camel code fields such as `pythonCode` or `cSharpCode`.
- Keep execution inside `createSession(globals, options)`. AxAgent passes `inputs`, `llmQuery`, `final`, `askClarification`, progress callbacks, memory/discovery primitives, and namespaced tools as host globals; the runtime decides how those globals appear in the target language.
- Put language syntax, output behavior, persistence semantics, and completion-call examples in `getUsageInstructions()`.
- Use `getPrimitiveOverrides()` to describe language-native calls for built-in primitives, and `formatCallable()` to describe language-native calls for tools and child agents.
- Implement `inspectGlobals()` on sessions when `contextPolicy` should show live runtime state for non-JavaScript runtimes; otherwise AxAgent will not run JavaScript fallback inspection snippets.
## RLM Test Harness
Use `agent.test(code, contextFieldValues?, options?)` when the user wants to validate runtime snippets against the actual AxAgent runtime environment without running the full actor/responder loop. With `AxJSRuntime`, those snippets are JavaScript.
```typescript
import { AxJSRuntime, agent, f, fn } from '@ax-llm/ax';
const runtime = new AxJSRuntime();
const tools = [
fn('sum')
.description('Return the sum of the provided numeric values')
.namespace('math')
.arg('values', f.number('Value to add').array())
.returns(f.number('Sum of all values'))
.handler(async ({ values }) =>
values.reduce((total, value) => total + value, 0)
)
.build(),
];
const toolHarness = agent('query:string -> answer:string', {
contextFields: [],
runtime,
functions: tools,
contextPolicy: { preset: 'checkpointed', budget: 'balanced' },
});
const toolOutput = await toolHarness.test(
'console.log(await math.sum({ values: [3, 5, 8] }))'
);
console.log(toolOutput);
```
Rules:
- `test(...)` creates a fresh runtime session per call.
- Context-field snippets run in the context/distiller runtime and expose `inputs` plus non-colliding top-level aliases for configured `contextFields`.
- Tool snippets should use an agent with no `contextFields`, or test the executor stage directly, so namespaced functions, child agents, and `llmQuery(...)` are in scope.
- In `AxJSRuntime`, do not rely on calling `inspectRuntime()` from inside `test(...)` snippets yet; prefer checking runtime globals directly inside the snippet.
- It returns the formatted runtime output string.
- It throws on runtime failures instead of returning LLM-style error strings.
- Do not call `final(...)` or `askClarification(...)` inside `test(...)` snippets.
- Pass only `contextFields` values to `test(...)`; it is not a general way to inject arbitrary non-context inputs.
- If the snippet uses `llmQuery(...)`, provide an AI service through the agent config or `options.ai`.
## `llmQuery(...)` Rules
Available forms:
- `await llmQuery(query, context?)`
- `await llmQuery({ query, context? })`
- `await llmQuery([{ query, context }, ...])`
Rules:
- `llmQuery(...)` forwards only the explicit `context` argument.
- Parent inputs, runtime variables, tool results, and discovered docs are not automatically available to `llmQuery(...)`; include any needed facts in `context`.
- `llmQuery(...)` is a direct semantic helper backed by an AxGen sub-query. It does not create a child AxAgent, does not run an actor runtime session, and does not have access to tools or discovery.
- Use batched `llmQuery([...])` only for independent semantic questions. Use serial calls when later work depends on earlier results.
- Pass compact named object context instead of huge raw parent payloads.
- Do not assume anything other than the returned string comes back from `llmQuery(...)`.
- `maxSubAgentCalls` is a shared budget for `llmQuery(...)` sub-queries across the top-level run.
- Single-call `llmQuery(...)` may return `[ERROR] ...` on non-abort failures.
- Batched `llmQuery([...])` returns per-item `[ERROR] ...`.
- If a result starts with `[ERROR]`, inspect or branch on it instead of assuming success.
Minimal example:
```javascript
const summary = await llmQuery('Summarize this incident', inputs.context);
if (summary.startsWith('[ERROR]')) {
console.log(summary);
} else {
console.log(summary);
}
```
Parallel semantic review example:
```javascript
const narrowedIncidents = incidents.map((incident) => ({
id: incident.id,
timeline: incident.timeline,
notes: incident.notes.slice(0, 1200),
}));
const [severityReview, followupReview] = await llmQuery([
{
query:
'Use discovery and available tools to review severity policy alignment. Return compact findings.',
context: {
incidents: narrowedIncidents,
rubric: 'severity-policy',
},
},
{
query:
'Use discovery and available tools to review postmortem and follow-up obligations. Return compact findings.',
context: {
incidents: narrowedIncidents,
rubric: 'postmortem-followup',
},
},
]);
const merged = await llmQuery(
'Merge these delegated reviews into one manager-ready summary with next steps.',
{
severityReview,
followupReview,
audience: inputs.audience,
}
);
```
Delegation decision guide:
- **JS-only**: deterministic logic such as filter, sort, count, regex, or date math -> do it inline.
- **Single-shot semantic**: needs LLM reasoning but no tools or multi-step exploration -> single `llmQuery(...)` with narrow context.
- **Specialist/tool delegation**: needs its own tools, discovery, runtime, or reusable role -> create a child `agent(...)` and pass it in `functions: [...]`.
- **Parallel semantic fan-out**: two or more independent semantic-only subtasks -> batched `llmQuery([...])`.
Context handling:
- Always narrow with JS before delegating. Never pass raw `inputs.*`.
- Name context keys semantically, e.g. `{ emails: filtered, rubric: 'classify-urgency' }`.
- Estimate total sub-query calls before fanning out. `maxSubAgentCalls` is shared across the run.
Patterns:
- Fan-Out / Fan-In: JS narrows into categories -> `llmQuery([...])` fans out per category -> JS or one more `llmQuery(...)` merges semantic results.
- Pipeline: serial `llmQuery(...)` calls where each depends on the prior result.
- Specialist tool use: call child agents or tools via their namespaced function globals, e.g. `await team.writer({ draft })`.
## Examples
Fetch these for full working code:
- [RLM](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm.ts) - RLM basic
- [RLM Long Task](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-long-task.ts) - RLM context policy
- [RLM Discovery](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-discovery.ts) - discovery mode, grouped tools, child agents as functions, and semantic `llmQuery(...)`
- [RLM Adaptive Replay](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-adaptive-replay.ts) - adaptive replay
Flagship real-world long-agents (also ported to Python, Go, Rust, Java, and C++ under `src/examples/<lang>/long-agents/`; run with `npm run example -- <lang> <path>`):
- [Incident Log Forensics](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/incident-log-forensics.ts) - large-context log forensics over `contextFields` (Gemini)
- [Codebase Peek Map](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/codebase-peek-map.ts) - Peek-paper context-map orientation over a large repo snapshot
- [Data Analyst with Tools](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/data-analyst-with-tools.ts) - large data dictionary in `contextFields` + typed warehouse tools the model queries instead of inlining
- [Smart Defaults Agent](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/smart-defaults-agent.ts) - oversized undeclared context auto-promoted runtime-only, with relevance hints and runtime tools
- [Self-Improving Lab](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/self-improving-lab.ts) - many-tool agent that runs experiments, grades them with an independent verifier, and distills verified rules into memory
## Do Not Generate
- Do not write a full multi-step RLM actor program in one turn.
- Do not combine `console.log(...)` with `final(...)`.
- Do not assume old successful turns stay fully replayed under adaptive/checkpointed/lean policies.
- Do not rebuild runtime state just because a prior turn was summarized.
- Do not describe `llmQuery(...)` as spawning a tool-using child AxAgent.
- Do not assume parent inputs are available to `llmQuery(...)` unless passed in `context`.
- Do not ignore `[ERROR] ...` results from `llmQuery(...)`.
- Do not grant `AxJSRuntime` permissions unless the user asked for the capability.

View File

@@ -1,693 +0,0 @@
---
name: ax-agent
description: This skill helps an LLM generate correct core AxAgent code using @ax-llm/ax. Use when the user asks about agent(), child agents, namespaced functions, discovery mode, clarification, bubbleErrors, host-side final/clarification protocol, or ordinary agent runtime behavior. For MCP clients, native runtime modules, subscriptions, tasks, or authentication use ax-mcp alongside this skill. For RLM/code-runtime work use ax-agent-rlm; for callbacks and telemetry use ax-agent-observability; for recall/memory/skill loading use ax-agent-memory-skills; for agent.optimize(...) use ax-agent-optimize.
version: "23.0.9"
---
# AxAgent Codegen Rules (@ax-llm/ax)
Use this skill to generate small, correct `AxAgent` code. Prefer modern factory-style APIs and copyable patterns. Do not write tutorial prose unless the user explicitly asks for explanation.
Your job is to choose the smallest correct `AxAgent` shape for the user's needs:
- If the user wants a normal tool-using assistant, keep the config minimal.
- If the user wants long-running code execution, use the `ax-agent-rlm` skill.
- If the user wants callbacks, logs, tracing, or usage data, use the `ax-agent-observability` skill.
- If the user wants dynamic memory retrieval or skill-guide loading, use the `ax-agent-memory-skills` skill.
- If the user wants tuning or eval with `agent.optimize(...)`, use the `ax-agent-optimize` skill.
- If the user wants MCP transports, authentication, catalogs, subscriptions,
tasks, Apps, or event-driven wake/resume, use the `ax-mcp` skill.
## Use These Defaults
- Use `agent(...)`, not `new AxAgent(...)`.
- Prefer string signatures or `f()` signatures over hand-written signature objects.
- Put `ai`, `judgeAI`, and `agentIdentity` on the `agent(...)` config when you want instance defaults or child-agent metadata.
- Prefer `fn(...)` for host-side function definitions instead of hand-writing JSON Schema objects.
- Prefer namespaced functions such as `utils.search(...)` or `kb.find(...)`.
- Pass child agents directly in `functions: [...]`. They land under their `agentIdentity.namespace` (or `utils` if unset), exactly like a `fn()` tool.
- If discovery is enabled, call `discover(...)` before using callables whose docs are not already in the prompt.
- Use explicit child agents in `functions: [...]` for specialist delegation; do not model that as recursive `llmQuery(...)`.
- Add `bubbleErrors` only for fatal infrastructure errors that should abort `.forward()`.
## Decision Guide
Map user intent to agent shape before writing code:
- "Use tools and answer" -> plain `agent(...)` with local functions, no extra observability.
- "Need child agents with distinct responsibilities" -> add child agents to the parent's `functions: [...]` list and set each child's `agentIdentity.namespace` when you want a specific runtime call site such as `team.writer(...)`.
- "Need tool discovery because names/schemas are not stable" -> enable discovery and generate discovery-first actor code.
- "Need certain errors to escape the agent loop" -> add `bubbleErrors` with error classes; those errors propagate through function handlers, actor code, and `llmQuery(...)` sub-queries to `.forward()`.
- "Inspect large context with code", "RLM", or "`llmQuery(...)`" -> use `ax-agent-rlm`.
- "Need debugging, traces, progress updates, tool-call logs, chat logs, or usage" -> use `ax-agent-observability`.
- "Need memories, recall, dynamic skill guides, `discover({ skills })`, or loaded/used tracking" -> use `ax-agent-memory-skills`.
## Critical Rules
- Use `agent(...)` factory syntax for new code.
- If an actor response contains multiple fenced code blocks, the runtime rejects the whole turn without executing any block and asks for one executable program.
- Add child agents to the parent's `functions: [...]` list. Each child's `agentIdentity.namespace` (or `utils`, the default) determines the runtime call site, e.g. `await team.writer({...})`.
- If discovery is enabled, call `discover(...)` before using callables whose docs are not already in the prompt.
- `autoUpgrade` is ON by default: large tool catalogs auto-enable discovery, and oversized undeclared input values are auto-kept runtime-only with a truncated prompt preview. Explicit `functionDiscovery` and declared `contextFields` always win; set `autoUpgrade: false` to opt out.
- `directResponse` is ON by default (`'auto'`): when a task needs no user-provided functions, the distiller ends the run with `respond(task, evidence)` and the executor stage is skipped (zero executor model calls). Function-less agents run respond-only every time; agents with functions offer `respond` under a conservative covenant (no live/fresh-state asks, no side effects, nothing a listed function/module domain covers). Set `directResponse: 'off'` to always run the executor.
- If a host-side `AxAgentFunction` needs to end the current actor turn, use `extra.protocol.final(...)` or `extra.protocol.askClarification(...)`.
- In public `forward()` and `streamingForward()` flows, `askClarification(...)` throws `AxAgentClarificationError`; it does not go through the responder.
- When resuming after clarification, prefer `error.getState()` from the thrown `AxAgentClarificationError`, then call `agent.setState(savedState)` before the next `forward(...)`.
- Errors listed in `bubbleErrors` bypass actor-loop catch blocks and propagate directly to the caller of `.forward()`.
- Child agents receive only the arguments the actor passes. Pass parent fields explicitly via `inputs.<field>` or use `inputUpdateCallback` when many calls need the same value.
- Audio input fields are transcribed before agent planner/executor/responder stages by default; internal agent stages receive text transcripts, not base64 audio.
## Canonical Pattern
```typescript
import { agent, ai, f } from '@ax-llm/ax';
const llm = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
});
const assistant = agent(
f()
.input('query', f.string())
.output('answer', f.string())
.build(),
{
agentIdentity: {
name: 'Assistant',
description: 'Answers user questions',
},
contextFields: [],
}
);
const result = await assistant.forward(llm, { query: 'What is TypeScript?' });
console.log(result.answer);
```
## Audio Inputs And Speech Outputs
Agents can accept audio inputs and return scripted speech artifacts. The runtime transcribes audio input fields before internal stages run, then synthesizes `:audio` outputs after the final structured response is selected.
```typescript
const voiceAgent = agent(
'recording:audio, question:string -> speech:audio, summary:string',
{
agentIdentity: {
name: 'Voice Assistant',
description: 'Answers spoken requests',
},
contextFields: [],
}
);
const result = await voiceAgent.forward(
llm,
{
recording: { data: base64Wav, format: 'wav' },
question: 'What should I do next?',
},
{
speech: {
transcribe: { model: 'gpt-4o-mini-transcribe' },
speak: { voice: 'alloy', format: 'mp3' },
},
}
);
console.log(result.summary);
console.log(result.speech.data);
```
Use direct `ax(...)` or `.chat()` if the model should receive native audio instead of a transcript-first agent pipeline.
## Child Agents As Tools
Child agents are passed in the parent's `functions` list. There is no separate `agents` option for new code. Each child agent's `agentIdentity.namespace` (or `utils`, the default) determines where it lands in the actor runtime. With `AxJSRuntime`, that produces JavaScript call sites such as `team.writer(...)`:
```typescript
const writer = agent('draft:string -> revision:string', {
agentIdentity: {
name: 'Writer',
description: 'Polishes drafts',
namespace: 'team',
},
contextFields: [],
});
const coordinator = agent('query:string -> answer:string', {
functions: [writer],
contextFields: [],
});
```
Generated runtime call:
```javascript
const result = await team.writer({ draft: '...' });
```
Without `agentIdentity.namespace`, the child lands under `utils.<name>` like any other tool:
```javascript
const result = await utils.writer({ draft: '...' });
```
Rules:
- Add child agents to `functions: [...]`, the same array as `fn(...)` tools.
- Set `agentIdentity.namespace` on the child to control its runtime call site.
- `onFunctionCall` observers receive `kind: 'internal'` for agent-derived calls and `kind: 'external'` for user-registered tools.
### Reserved namespace names
The agent runtime injects a fixed set of globals into the runtime session. These names cannot be used as `agentIdentity.namespace` values or as agent-function namespaces.
```text
inputs
llmQuery
final
askClarification
reportSuccess
reportFailure
inspectRuntime
discover
recall
```
Pick any other lowercase identifier such as `utils`, `kb`, `tools`, `team`, or `db`.
## Tool Functions And Namespaces
```typescript
import { agent, f, fn } from '@ax-llm/ax';
const findSnippets = fn('findSnippets')
.description('Find handbook snippets by topic')
.namespace('kb')
.arg('topic', f.string('Topic keyword'))
.returns(f.string('Matching snippet').array())
.example({
title: 'Find severity guidance',
code: 'await kb.findSnippets({ topic: "severity" });',
})
.handler(async ({ topic }) => [])
.build();
const analyst = agent('query:string -> answer:string', {
functions: [findSnippets],
contextFields: [],
});
```
Generated runtime call:
```javascript
const snippets = await kb.findSnippets({ topic: 'severity' });
```
Rules:
- Prefer namespaced functions.
- Default function namespace is `utils` when no namespace is set.
- With `AxJSRuntime`, use the runtime call shape `await <namespace>.<name>({...})`. Custom runtimes should expose equivalent namespaced calls through their own `formatCallable()` guidance.
- `.arg()` and `.returns()` can use Ax field helpers or any Standard Schema v1 validator directly.
## Grouped Function Modules
For discovery mode, group functions into modules using the `AxAgentFunctionGroup` shape when you want a clean namespace tree such as `kb.find(...)` or `metrics.score(...)` without setting `namespace` on every individual `fn(...)`:
```typescript
const parent = agent('query:string -> answer:string', {
functions: [
{
namespace: 'kb',
title: 'Knowledge Base',
selectionCriteria: 'Use for handbook and documentation lookups.',
description: 'Knowledge base lookups',
functions: [findSnippetsFn, searchPagesFn],
},
{
namespace: 'workflow',
title: 'Workflow Controls',
description: 'Small control functions the actor should always see',
alwaysInclude: true,
functions: [completeFn],
},
],
functionDiscovery: true,
contextFields: [],
});
```
Attach MCP/UCP clients through the native execution context. Ax initializes them once, exposes `mcp.<namespace>` / `ucp.<namespace>` runtime modules, and propagates them through actor stages, `llmQuery`, RLM, and child agents:
Use `ax-mcp` for constructing those clients, transport/authentication policy,
server-initiated handlers, resource subscriptions, task continuations, and
recording/replay. Keep this section focused on Agent attachment and discovery.
```typescript
const parent = agent('query:string -> answer:string', {
mcp: [memoryClient, searchClient],
mcpInheritance: 'all',
functionDiscovery: true,
contextFields: [],
});
// A child can restrict inheritance to selected namespaces or `none`.
await parent.forward(llm, { query }, { mcpInheritance: ['memory'] });
```
Rules:
- A group is `{ namespace, title, description, functions: [...] }`.
- `selectionCriteria` is optional but useful in discovery mode; it tells the actor when to choose that module.
- The group's `namespace`, `title`, `selectionCriteria`, and `description` show up in `discover(...)` module docs.
- `relevanceRanking` (default ON — set `false` to opt out): a deterministic local ranker that injects an advisory `### Likely Relevant` shortlist into the executor turn (dynamic, non-cached field — the cached prompt stays byte-stable). Enabled by default after its A/B gate passed on both small and frontier models and implemented in the generated language ports through AxIR Core. Details in `ax-agent-memory-skills`; outcomes observable via the `relevance_ranking` context event (`ax-agent-observability`).
- Add `alwaysInclude: true` to a group when discovery mode is on but the actor should always see that group's full callable definitions inline in the prompt.
- Keep `functions: [...]` either flat or grouped. Runtime validation rejects mixed plain function entries and group objects.
- In flat mode, pass `fn(...)` tools and child agents directly.
- In grouped mode, put callable entries inside groups. To expose a child agent inside a group, use `childAgent.getFunction()`.
- Do not place MCP clients in `functions`; use `mcp` so tasks, resources, subscriptions, elicitation, sampling, authorization, cancellation, and protocol metadata remain available.
- To wake an Agent from a resource subscription, use `AxMCPEventSource` and an
explicit authenticated `wake` route. MCP sessions are not tenant identity;
supply identity from the application's authenticated token mapping.
- An endpoint does not imply a resource URI. Inspect `client.inspectCatalog()`
and choose an explicit `resourceSubscriptions` policy. Omission means none;
`'all'` selects all discovered concrete resources; selectors can use names,
descriptions, MIME types, URIs, and annotations. Templates are not expanded.
- Map the event with a signature-aware `.wakeInput(...)` plan, or reuse an
`eventInput()` plan. Callback `mapInput` is still signature-validated and
cannot inject undeclared Agent fields. Use multiple matching routes to wake
multiple Agents with independent state, authorization, retries, and runs.
- To wake from a UCP lifecycle webhook, use `AxUCPWebhookEventSource` and map
verified profile/account state to Ax tenant identity after request
verification. Never derive tenant identity from the order payload.
## Host-Side Completion From Functions
Use this pattern when the actor should call a namespaced function, but the host-side function implementation should decide to end the turn:
```typescript
import { f, fn } from '@ax-llm/ax';
const finishReply = fn('finishReply')
.description('Complete the actor turn with the final reply text')
.namespace('workflow')
.arg('reply', f.string('Final reply text'))
.returns(f.string('Final reply text'))
.handler(async ({ reply }, extra) => {
extra?.protocol?.final(reply);
return reply;
})
.build();
const askForOrderId = fn('askForOrderId')
.description('Complete the actor turn by requesting clarification')
.namespace('workflow')
.arg('question', f.string('Clarification question'))
.returns(f.string('Clarification question'))
.handler(async ({ question }, extra) => {
extra?.protocol?.askClarification(question);
return question;
})
.build();
```
Rules:
- `extra.protocol` is only available when the function call comes from an active AxAgent actor runtime session.
- Use `extra.protocol.final(...)`, `extra.protocol.askClarification(...)`, or `extra.protocol.guideAgent(...)` only inside host-side function handlers.
- Inside actor-authored runtime code, use the runtime globals `final(...)` and `askClarification(...)` with the syntax documented by the active runtime.
- `extra.protocol.guideAgent(...)` is handler-only internal control flow. It stops the current actor turn and appends trusted guidance to `guidanceLog` for the next iteration.
- `askClarification(...)` accepts either a simple string or a structured object with `question` plus optional UI hints such as `type: 'date' | 'number' | 'single_choice' | 'multiple_choice'` and `choices`.
## Clarification And Resume State
Use this pattern when the actor should pause for user input and continue later from the same runtime state.
```typescript
import {
AxAgentClarificationError,
AxJSRuntime,
agent,
ai,
} from '@ax-llm/ax';
const llm = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
});
const tripAgent = agent('request:string, answer?:string -> reply:string', {
contextFields: [],
runtime: new AxJSRuntime(),
});
let savedState = tripAgent.getState();
try {
await tripAgent.forward(llm, {
request: 'Plan a Lisbon trip',
});
} catch (error) {
if (error instanceof AxAgentClarificationError) {
console.log(error.question);
savedState = error.getState();
} else {
throw error;
}
}
if (savedState) {
tripAgent.setState(savedState);
const resumed = await tripAgent.forward(llm, {
request: 'Plan a Lisbon trip',
answer: 'June 1-5',
});
console.log(resumed.reply);
}
```
Public flow rules:
- `forward()` and `streamingForward()` throw `AxAgentClarificationError` when the actor calls `askClarification(...)`.
- Successful `final(...)` completions always continue through the responder in public flows.
- `AxAgentClarificationError.question` is the user-facing question text.
- `AxAgentClarificationError.clarification` is the normalized structured payload.
- `AxAgentClarificationError.getState()` returns the saved continuation state captured at throw time.
- `agent.getState()` and `agent.setState(...)` export or restore continuation state on the agent instance.
- `test(...)` is different: it returns structured completion payloads for harness/debug use instead of throwing clarification exceptions.
Structured clarification payloads:
- String shorthand is allowed: `askClarification("What dates should I use?")`.
- Structured form is preferred for richer chat UIs:
```javascript
askClarification({
question: 'Which route should I use?',
type: 'single_choice',
choices: ['Fastest', 'Scenic'],
});
```
- Supported `type` values are `text`, `number`, `date`, `single_choice`, and `multiple_choice`.
- `single_choice` payloads with missing, empty, or malformed `choices` are downgraded to a plain clarification question instead of failing the turn.
- `multiple_choice` payloads must include at least two valid choices; otherwise the actor turn fails with a corrective runtime error.
- Choice entries may be strings or `{ label, value? }` objects.
- Invalid clarification payloads such as a missing `question` are actor-turn runtime errors, not successful clarification completions.
State notes:
- `runtimeBindings` restores execution state; `runtimeEntries`, `actionLogEntries`, and `checkpointState` restore prompt context.
- Resume does not create a fake rehydration action-log turn; provenance still points to the original actor code that set the value.
- Only serializable/structured-clone-friendly values are guaranteed to round-trip through `getState()` / `setState(...)`.
- Reserved runtime globals such as `inputs`, tools, and protocol helpers are rebuilt fresh and are not part of saved state.
- Treat one agent instance as conversation-scoped when using `setState(...)`; do not share one mutable resumed instance across unrelated concurrent conversations.
## Bubble Errors
Use `bubbleErrors` when certain exceptions thrown inside function handlers or `llmQuery(...)` sub-query calls should propagate all the way out to `.forward()` instead of being caught by the actor loop and returned as `[ERROR]` strings.
```typescript
import { agent, f, fn } from '@ax-llm/ax';
class DatabaseError extends Error {
constructor(message: string) {
super(message);
this.name = 'DatabaseError';
}
}
const dbTool = fn('queryUsers')
.description('Query the user database')
.namespace('db')
.arg('filter', f.string('Filter expression'))
.returns(f.string('JSON result'))
.handler(async ({ filter }) => {
if (!isConnected()) throw new DatabaseError('DB connection refused');
return JSON.stringify(await db.query(filter));
})
.build();
const myAgent = agent('query:string -> answer:string', {
contextFields: [],
functions: [dbTool],
bubbleErrors: [DatabaseError],
});
```
Rules:
- `bubbleErrors` takes an array of Error constructor classes, checked via `instanceof`.
- A matching error thrown inside a function handler, during actor code execution, or inside an `llmQuery(...)` sub-query propagates immediately to `.forward()`.
- Use `bubbleErrors` for fatal infrastructure errors such as DB down, auth failure, or quota exceeded.
- Do not use `bubbleErrors` for expected recoverable errors; let those return as `[ERROR] ...` strings so the actor can handle them.
- `AxAgentClarificationError` and `AxAIServiceAbortedError` always bubble up unconditionally.
## Unified Final Signal
There are two ways to end a successful run through the responder:
1. In actor JS code, call `final(message)` when no extra context object is needed, or `final(task, context)` when you gathered evidence.
2. In function handlers, use `extra.protocol.final(...)` with the same one-arg or two-arg forms.
Rules:
- Use `final(message)` when the actor already knows the answer and no extra context object is needed.
- Use `final(task, context)` when context was gathered and needs synthesis into output fields.
- In function handlers, use `extra.protocol.final(...)` instead of a separate respond API.
- The responder still runs for both successful `final(...)` forms.
- Use `askClarification(...)` when the user must provide more information to continue.
## Discovery Mode
Enable discovery mode when you want the actor to discover modules and fetch callable definitions on demand:
```typescript
const analyst = agent('context:string, query:string -> answer:string', {
agentIdentity: {
name: 'Analyst',
description: 'Analyzes long context',
namespace: 'team',
},
contextFields: ['context'],
functions: [writer, ...tools],
functionDiscovery: true,
});
```
Discovery API:
- `await discover(item: string): void`
- `await discover(items: string[]): void`
- `await discover({ tools?: string | string[], skills?: string | string[] }): void` when `onSkillsSearch` is configured
Discovery returns `void`; fetched docs render in the next executor prompt.
Rules:
- `discover('kb')` loads a module callable list when `kb` is a discoverable module.
- `discover('kb.findSnippets')` loads a full callable definition.
- `discover('lookup')` resolves as `utils.lookup`.
- `discover({ tools: ['kb'], skills: ['release-checklist'] })` loads tool docs and skill bodies in one turn.
- Call one batched `discover(...)` with every module, callable, and skill you need.
- Do not split discovery into separate calls or wrap discovery in `Promise.all(...)`.
- Read the next prompt's "Discovered Tool Docs" and "Loaded Skills" sections.
- If a guessed call fails, stop guessing nearby names. Run `discover(...)` for that module or function and call only the exact discovered qualified name.
## Threading Parent Fields Into Child Agents
If a child agent requires a parent field such as `audience`, declare it on the child's signature and pass it explicitly when calling the child from the actor:
```typescript
const writingCoach = agent('draft:string, audience:string -> revision:string', {
agentIdentity: {
name: 'Writing Coach',
description: 'Polishes summaries for a target audience',
namespace: 'team',
},
contextFields: [],
});
const analyst = agent('context:string, audience:string, query:string -> answer:string', {
functions: [writingCoach],
contextFields: ['context'],
});
```
Generated runtime call:
```javascript
const polished = await team.writingCoach({
draft: summary,
audience: inputs.audience,
});
```
Rules:
- Pass parent fields explicitly via the call site.
- If many children need the same field on every call, use `inputUpdateCallback` to inject the value before each executor turn.
- Do not assume auto-propagation; child agents receive only the args the actor passes.
## Core API Reference
Factory shape:
```typescript
agent(signature, {
ai,
judgeAI,
agentIdentity,
contextFields,
functions,
functionDiscovery,
autoUpgrade,
playbook,
citations,
...agentOptions,
});
```
- `ai` is an optional default service for the agent instance; `.forward(ai, ...)` can still pass the runtime service.
- `judgeAI` is the optional default judge/teacher service used by optimize flows.
- `agentIdentity` controls the user-facing agent identity and child-agent function metadata.
```typescript
agentIdentity?: {
name: string;
description: string;
namespace?: string;
}
```
- `name` is normalized to camelCase for child-agent function names.
- `name` and `description` are included in the actor and responder prompts as the user-facing agent identity.
- `namespace` changes the child-agent module from default `utils` to a custom module such as `team`.
Each `contextFields` entry is either a plain field name string or an object controlling how much of the value is inlined into the distiller prompt:
- `{ field, promptMaxChars: N }`: inline only when the serialized value is at most `N` chars; otherwise omit it from the prompt and keep it runtime-only.
- `{ field, keepInPromptChars: N, reverseTruncate?: boolean }`: always inline a truncated string excerpt; `reverseTruncate: true` keeps the last `N` chars.
Use `promptMaxChars` when partial data is worse than no data. Use `keepInPromptChars` when a prefix or suffix alone is useful. The two options are mutually exclusive on one field.
### Auto-upgrade defaults
`autoUpgrade` is ON by default: the agent applies both knobs above on the user's behalf based on character counts, so forgetting them no longer floods prompts.
- Function discovery: when `functionDiscovery` is left unset and the estimated inline docs of discoverable functions exceed ~10k chars, discovery is enabled automatically. An explicit `functionDiscovery: true | false` always wins.
- Context fields: per run, an undeclared input value whose serialized size exceeds 8k chars is kept runtime-only like a declared context field — the prompt gets a 1,200-char truncated preview plus a `contextMetadata` entry, while the full value stays addressable as `inputs.<field>` in the code runtime (the responder stage gets the same preview). Fields declared in `contextFields` keep their declared config.
```typescript
autoUpgrade?: boolean | {
functionDiscovery?: boolean | { aboveFunctionDocChars?: number }; // default 10_000
contextFields?: boolean | { promoteAboveChars?: number; previewChars?: number }; // 8_000 / 1_200
}
```
Rules:
- Set `autoUpgrade: false` (or disable one side) to restore fully manual behavior.
- Values in required non-string fields (arrays, objects, numbers, media) are never auto-promoted — declare those in `contextFields` explicitly when they can be large.
- Each promotion emits a `field_auto_promoted` context event (`onContextEvent`) with the field name, original size, and preview size; use it to observe what was kept out of the prompt.
### Learning And Citations
These construction-time options are portable across TypeScript and the generated
Python, Java, C++, Go, and Rust packages (both default off):
- `playbook`: attach an ACE playbook at construction. `learn` is on by default — after each run that produced failure signals (error turns, dead-ends, failing tool calls) one bounded update curates durable avoidance rules that ride the next run's actor prompt; zero LLM cost on clean runs. TypeScript seeds a prior session with `playbook: { playbook: snapshot }`; generated packages accept their full `{ playbook, artifact }` snapshot under `seed`. Persist via `onUpdate`, read the live handle with `getPlaybook()` (or the language-shaped equivalent), gate with `learn: { minSignals, dedupe }`, or disable with `learn: false`. To grow the same playbook from a task set with a held-out verify gate, use the agent-bound playbook evolve method — see `ax-playbook`.
- `citations`: add an optional `evidenceCitations: string[]` responder output listing which evidence entries (top-level keys of the curated evidence, plus memory ids) the answer relied on. Validated in-pipeline — the model cannot cite evidence it never collected (existence, not entailment). Pass `true`, or `{ field?, surface?: 'output' | 'hidden', includeMemoryIds?, onCitations? }`.
Stage guidance is portable too. `setInstruction` replaces the stage-owned actor
instruction and `addActorInstruction` appends an additive rule. Both are real
optimization components; rebuilding the split programs no longer discards them.
Generated packages use their normal casing conventions (`set_instruction` in
Python/C++/Rust and `SetInstruction` in Go).
The generated-language observer and evolve spellings are:
| Language | Citations observer | Verified agent playbook evolve |
|---|---|---|
| Python | `citations.onCitations` | `agent.playbook().evolve(dataset, options)` |
| Java | `citations.onCitations` (`Consumer`) | `agent.playbook(null).evolve(dataset, options)` |
| C++ | `set_citations_observer(...)` | `agent.get_playbook()->evolve(dataset, options)` |
| Go | `citations.onCitations` (`func([]Value)`) | `agent.GetPlaybook().EvolveAgent(ctx, dataset, options)` |
| Rust | `set_citations_observer(...)` | `playbook.evolve_agent(&mut agent, client, dataset, options)` |
C++ and Rust use `set_playbook_observer(...)` for construction-time learning
updates; Python, Java, and Go accept the `playbook.onUpdate` callback in their
native configuration map.
## Public Surface
Use these method groups as the compact AxAgent surface map:
- Running: `forward(ai, values, options?)` and `streamingForward(ai, values, options?)`.
- Forward-time agent options: `skills`, `onUsedMemories`, and `onUsedSkills`; use `ax-agent-memory-skills` for details.
- State and control: `getState()`, `setState(state?)`, `getContextMap()`, `setContextMap(map?)`, `stop()`, `getSignature()`, `setSignature(signature)`, `getFunction()`, `getId()`, and `setId(id)`. Context-map evolve policy lives on `AxAgentContextMap` (`infiniteEvolve`, `evolveSteps`, `maxChars`), not on the agent config. See [`src/examples/rlm-context-map-live.ts`](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/rlm-context-map-live.ts) for provider-backed persistence and finite-evolve usage.
- Observability: `getChatLog()`, `getUsage()`, `getStagedUsage()`, `resetUsage()`, and `getTraces()`; use `ax-agent-observability` for details.
- Demos and tuning: `setDemos(...)`, `namedPrograms()`, `namedProgramInstances()`, `optimize(...)`, `applyOptimization(...)`, `getOptimizableComponents()`, and `applyOptimizedComponents(...)`; use `ax-agent-optimize` for tuning details.
- Learning: `playbook()` returns an agent-aware playbook handle (`update(...)`, `render()`, state/load methods, and verified dataset evolution); `getPlaybook()` reads the current handle. Generated packages expose the same behavior with language-shaped method names. Use `ax-playbook` for details.
Rules:
- `getFunction()` requires `agentIdentity` because the agent needs function metadata when used as a child tool.
- Prefer `.forward(...)` for normal runs and `.streamingForward(...)` only when the caller needs streamed responder output.
- `setSignature(...)` must preserve configured `contextFields`; it throws if a configured context field is missing from the new signature.
- Treat low-level optimization component methods as advanced hooks; normal examples should use `agent.optimize(...)` and `agent.applyOptimization(...)`.
## Tuning Hand-off
When the user wants `agent.optimize(...)`, judge configuration, eval datasets, saved optimization artifacts, or optimization guidance, use `ax-agent-optimize`.
Keep this skill focused on building and running agents. For tuning work:
- use eval-safe tools
- treat `judgeOptions` as part of the optimize workflow
- choose an objective `metric` when scoring is mechanical; use the built-in judge only when run quality needs qualitative review
- keep runtime authoring guidance here and optimization guidance in `ax-agent-optimize`
## Examples
Fetch these for full working code:
- [Agent](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/agent.ts) - basic agent
- [Functions](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/function.ts) - function validation
- [Food Search](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/food-search.ts) - API tools
- [Smart Home](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/smart-home.ts) - state management
- [Customer Support](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/customer-support.ts) - classification agent
- [Abort Patterns](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/abort-patterns.ts) - abort handling
- [Smart Defaults Agent](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/long-agents/smart-defaults-agent.ts) - auto-upgrade context promotion, relevance hints, and runtime tools
- [Portable Playbook Evolve](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/python/optimization/agent-playbook-evolve.py) - Python construction-time learning, validated citations, stage guidance, and verified task-set evolution (parallel Java/C++/Go/Rust examples live beside it)
RLM examples are listed in `ax-agent-rlm`. Memory/skills examples are listed in `ax-agent-memory-skills`.
## Event-Driven Agents
`AxEventRuntime` can wake or resume an Agent while preserving its logical
state. Use `createProgram(instance)` for multi-tenant Agents; one mutable Agent
object must not serve multiple instance keys concurrently. Clarification and
remote task completion are represented as owned continuations, not synthetic
user turns.
Declare the Agent signature on
`eventTarget('id').createProgram(signature, factory)` and map event values with
`eventPath`. The runtime verifies every created Agent against that signature
before invoking it. Fan-out uses multiple matching routes so each Agent keeps
its own authorization, instance serialization, retry policy, and run record.
## Do Not Generate
- Do not use `new AxAgent(...)` for new code unless explicitly required.
- Do not assume child agents are always under `agents.*`.
- Do not guess function names in discovery mode.
- Do not write a full multi-step RLM actor program in one turn; use `ax-agent-rlm`.
- Do not combine `console.log(...)` with `final(...)`.
- Do not add `bubbleErrors` for ordinary recoverable tool errors.
- Do not call `discover()` from the distiller or responder stages.
- Do not assign or inspect the return value of `await discover(...)`; read the next prompt instead.
- Do not loop `discover()` calls or wrap them in `Promise.all`.

View File

@@ -1,497 +0,0 @@
---
name: ax-ai
description: This skill helps an LLM generate correct AI provider setup and configuration code using @ax-llm/ax. Use when the user asks about ai(), providers, models, routing, adaptive balancing, presets, embeddings, batch audio with ai.transcribe() or ai.speak(), extended thinking, context caching, or mentions OpenAI/Anthropic/Google/Azure/DeepSeek/Mistral/Cohere/Reka/Grok with @ax-llm/ax.
version: "23.0.9"
---
# AI Provider Codegen Rules (@ax-llm/ax)
Use this skill to generate AI provider setup, configuration, and chat code. Prefer short, modern, copyable patterns. Do not write tutorial prose unless the user explicitly asks for explanation.
## Quick Setup
```typescript
import { ai } from '@ax-llm/ax';
const openai = ai({ name: 'openai', apiKey: 'sk-...' });
const claude = ai({ name: 'anthropic', apiKey: 'sk-ant-...' });
const gemini = ai({ name: 'google-gemini', apiKey: 'AIza...' });
const azure = ai({ name: 'azure-openai', apiKey: 'your-key', resourceName: 'your-resource', deploymentName: 'gpt-5-4-mini' });
const deepseek = ai({ name: 'deepseek', apiKey: 'sk-...' });
const mistral = ai({ name: 'mistral', apiKey: 'your-key' });
const cohere = ai({ name: 'cohere', apiKey: 'your-key' });
const custom = ai({
name: 'openai',
apiKey: process.env.PROVIDER_API_KEY,
apiURL: 'https://example.com/v1',
config: { model: 'provider/model-name' },
});
const reka = ai({ name: 'reka', apiKey: 'your-key' });
const grok = ai({ name: 'grok', apiKey: 'your-key' });
const compatible = ai({ name: 'openai', apiKey: 'key', apiURL: 'https://api.example.com/v1', config: { model: 'provider/model' } });
```
<!-- axir-nonportable:start webllm -->
WebLLM is browser-only and requires a host-created WebLLM engine. The host
loads or reloads models with WebLLM APIs such as `CreateMLCEngine(...)`; Ax
only forwards chat requests to that loaded engine. Do not present WebLLM as a
portable AxIR provider or a server-side default.
```typescript
import { ai, AxAIWebLLMModel } from '@ax-llm/ax';
const engine = await CreateMLCEngine(AxAIWebLLMModel.Llama32_3B_Instruct);
const llm = ai({
name: 'webllm',
engine,
config: {
model: AxAIWebLLMModel.Llama32_3B_Instruct,
stream: false,
supportsFunctions: false,
},
});
```
<!-- axir-nonportable:end webllm -->
## Model Presets
```typescript
import { ai, AxAIGoogleGeminiModel } from '@ax-llm/ax';
const gemini = ai({
name: 'google-gemini',
apiKey: process.env.GOOGLE_APIKEY!,
config: { model: 'simple' },
models: [
{ key: 'tiny', model: AxAIGoogleGeminiModel.Gemini35FlashLite, description: 'Fast + cheap', config: { maxTokens: 1024 } },
{ key: 'simple', model: AxAIGoogleGeminiModel.Gemini36Flash, description: 'Balanced' },
],
});
await gemini.chat({ model: 'tiny', chatPrompt: [{ role: 'user', content: 'Hi' }] });
```
## Model Catalog
```typescript
import { axGetSupportedAIModels } from '@ax-llm/ax';
const providers = axGetSupportedAIModels();
const openai = providers.find((provider) => provider.name === 'openai');
console.log(openai?.models[0]?.promptTokenCostPer1M);
const textProviders = axGetSupportedAIModels({ type: 'text' });
const embeddingProviders = axGetSupportedAIModels({ type: 'embeddings' });
```
Use `axGetSupportedAIModels()` to build provider/model selectors before creating an `ai(...)` instance. It returns bundled static metadata: provider names, display names, default models, raw `AxModelInfo` pricing/details, model type (`'text'`, `'embeddings'`, `'code'`, or `'audio'`), and normalized capability flags for thinking, thoughts, structured outputs, audio, temperature, and top-p support. Provider groups and models are sorted cheapest to most expensive based on bundled input + output token pricing; unpriced models sort last.
Filter with `{ type: 'all' | 'text' | 'embeddings' | 'code' | 'audio' }` or an array of those values. The `'text'` filter includes code-capable models; use `'code'` to show only code-first models.
Dynamic providers such as Azure OpenAI deployments are marked with `isDynamic: true` and may have an empty or static-limited model list.
## Routing And Balancing
Choose the primitive by responsibility:
- `AxMultiServiceRouter` combines model lists and dispatches the model key the caller already selected. It does not select a model.
- `AxBalancer` without a strategy orders equivalent services once with a comparator, retries transient provider failures, and fails over in that order.
- `AxBalancer` with `strategy.type: 'adaptive'` selects among services exposing the same logical model aliases using learned provider reliability, successful latency, and estimated cost.
Adaptive balancing is operational routing, not semantic prompt-to-model routing. Every provider model behind an alias must be an acceptable substitute for that application. Keep quality evaluation and content-aware model selection outside the balancer.
```typescript
import { AxBalancer, AxInMemoryBalancerStatsStore } from '@ax-llm/ax';
const statsStore = new AxInMemoryBalancerStatsStore();
const routeKeys = new Map<string, string>([
[openai.getId(), 'openai-primary'],
[anthropic.getId(), 'anthropic-primary'],
]);
const llm = AxBalancer.create([openai, anthropic] as const, {
strategy: {
type: 'adaptive',
deadlineMs: 6_000,
badOutcomeCost: 0.02,
expectedTokens: { promptTokens: 1_200, completionTokens: 300 },
namespace: 'support-v1',
routeKey: (service) => {
const key = routeKeys.get(service.getId());
if (!key) throw new Error('Missing stable route key.');
return key;
},
slice: ({ options }) =>
options?.customLabels?.workflow ?? 'default-workflow',
statsStore,
onRoutingEvent: (event) => telemetry.emit('llm.route', event),
},
});
```
The score is estimated request cost plus `badOutcomeCost` times the probability of provider failure or missing `deadlineMs`. `badOutcomeCost` and estimated cost must use the same currency or unit. By default, cost uses `expectedTokens`, the route's concrete model mapping, and `getEstimatedCost()`; missing catalog pricing contributes zero, while `estimateCost` can supply application pricing. Failures use an EWMA; successful latency is modeled in log space with a Normal-Inverse-Gamma posterior, and Thompson sampling supplies the deadline risk. Capability filtering still runs before ranking.
Rules:
- Reuse the in-memory store across balancers in one process. For multiple processes, implement `AxBalancerStatsStore` with Redis or an application database; its `observe()` operation must be atomic.
- A custom store requires stable, unique `routeKey` values. Stats are partitioned by `namespace`, `slice`, logical model, and route.
- `statsStore` is decision state. `onRoutingEvent` is best-effort telemetry and must not be used as the authoritative routing state.
- Routing events contain scores, route metadata, and sanitized failure categories, never prompts, responses, or raw provider errors.
- Adaptive mode attempts each candidate once. Provider-client retries remain controlled by `AxAIServiceOptions.retry`.
- Streaming can fail over before the first emitted chunk. A mid-stream transient failure is learned but cannot be replayed after partial output; caller cancellation is not recorded.
- Adaptive selection applies to chat. Embedding, transcription, and speech keep existing balancer behavior.
See the [adaptive balancer example](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/generation/adaptive-balancer.ts) for complete provider setup.
## Chat
```typescript
const res = await llm.chat({
chatPrompt: [
{ role: 'system', content: 'You are concise.' },
{ role: 'user', content: 'Write a haiku about the ocean.' },
],
});
console.log(res.results[0]?.content);
```
## Batch Audio
Use `ai.transcribe(...)` for batch speech-to-text and `ai.speak(...)` for batch text-to-speech. These are separate from conversational `.chat()` audio config.
```typescript
const transcript = await llm.transcribe({
audio: { data: base64Wav, format: 'wav' },
model: 'gpt-4o-mini-transcribe',
language: 'en',
});
const speech = await llm.speak({
text: transcript.text,
model: 'gpt-4o-mini-tts',
voice: 'alloy',
format: 'mp3',
});
console.log(transcript.text);
console.log(speech.data);
```
Providers without the requested audio endpoint throw `AxMediaNotSupportedError`. Use `speech` forward options for signature audio artifacts and `modelConfig.audio` for conversational chat audio.
## Common Options
- `stream` (boolean): enable SSE; true by default
- `thinkingTokenBudget`: `'minimal'` | `'low'` | `'medium'` | `'high'` | `'highest'` | `'none'`
- `showThoughts`: include thoughts in output
- `functionCallMode`: `'auto'` | `'native'` | `'prompt'`
- `debug`, `logger`, `tracer`, `rateLimiter`, `timeout`
## Global Runtime Defaults
Use `axGlobals` when the app wants one live default for AI requests, generator runs, flows, or metrics:
```typescript
import { ai, axGlobals, axCreateDefaultColorLogger } from '@ax-llm/ax';
import { trace } from '@opentelemetry/api';
axGlobals.tracer = trace.getTracer('my-app');
axGlobals.debug = true;
axGlobals.logger = axCreateDefaultColorLogger();
axGlobals.customLabels = { service: 'api' };
axGlobals.onUsage = (event) => usageQueue.enqueue(event);
const llm = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY! });
```
Rules:
- `axGlobals.tracer`, `meter`, `logger`, `debug`, `abortSignal`, and `customLabels` are live runtime defaults; future calls read the current value even if the AI instance already exists.
- Precedence is: per-call options, then explicit AI/service options, then current `axGlobals`, then built-in defaults.
- `customLabels` merge from globals to service to call options; later sources override earlier keys.
- `abortSignal` values are merged, so either a global shutdown signal or a local request signal can cancel the request.
- `axGlobals.onUsage` receives one immutable normalized event for each completed chat or embedding call that reports token usage. A fully consumed stream emits once.
- Usage observers are best-effort and fail-open. Ax does not await them; synchronously enqueue events and persist or aggregate them out of band.
Use `usageContext` for multi-tenant and request attribution:
```typescript
const llm = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
options: {
usageContext: {
tenantId: 'tenant-42',
feature: 'support-chat',
attributes: { environment: 'production' },
},
},
});
await llm.chat(request, {
usageContext: {
userId: user.id,
requestId: requestId,
runId: runId,
},
});
```
Per-call context overrides service defaults, while `attributes` are shallow-merged. Events include normalized tokens, provider/model, available session and remote IDs, and a streaming flag. They do not estimate currency cost; calculate that downstream against a versioned pricing table.
## DeepSeek Notes
```typescript
import { ai, AxAIDeepSeekModel } from '@ax-llm/ax';
const deepseek = ai({
name: 'deepseek',
apiKey: process.env.DEEPSEEK_APIKEY!,
config: { model: AxAIDeepSeekModel.DeepSeekV4Flash },
});
```
DeepSeek's current API models are `deepseek-v4-flash` and `deepseek-v4-pro`.
The deprecated `deepseek-chat` and `deepseek-reasoner` aliases are retained for
compatibility until DeepSeek removes them on 2026-07-24.
DeepSeek V4 supports thinking mode. Ax sends `thinking: { type: "disabled" }`
by default to preserve non-thinking behavior, and enables it when
`thinkingTokenBudget` is set. Ax maps lower budget levels to DeepSeek's `high`
effort and maps `highest` to `max`. DeepSeek V4 thinking models support tools,
but reject the `tool_choice` request parameter, so Ax omits forced/auto tool
choice for `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-reasoner`
while still sending tool definitions.
## Extended Thinking
```typescript
import { ai, AxAIAnthropicModel } from '@ax-llm/ax';
const claude = ai({
name: 'anthropic',
apiKey: process.env.ANTHROPIC_APIKEY!,
config: { model: AxAIAnthropicModel.Claude48Opus },
});
const res = await claude.chat(
{ chatPrompt: [{ role: 'user', content: 'Solve step by step...' }] },
{ thinkingTokenBudget: 'medium', showThoughts: true },
);
console.log(res.results[0]?.thought);
console.log(res.results[0]?.content);
```
### Budget Levels
| Level | Anthropic (tokens) | Gemini (tokens) |
|---|---|---|
| `'none'` | disabled | minimal |
| `'minimal'` | 1,024 | 200 |
| `'low'` | 5,000 | 800 |
| `'medium'` | 10,000 | 5,000 |
| `'high'` | 20,000 | 10,000 |
| `'highest'` | 32,000 | 24,500 |
For GPT-5.6, these map to `none`, `low`, `low`, `medium`, `high`, and a top rung
that depends on the API surface: `xhigh` on Chat Completions, which rejects
`max`, and `max` on the Responses API, which is the only place it is served.
Earlier OpenAI models retain their existing mapping.
### Anthropic Model-Specific Behavior
- Opus 4.8, 4.7, and 4.6 plus Sonnet 5: adaptive thinking, no manual
`budget_tokens`, and no `temperature` / `topP` / `topK`. When thoughts are
requested, Ax asks Anthropic for summarized display; when they are hidden,
Ax explicitly requests `display: 'omitted'`.
- Opus 4.5: budget_tokens + effort levels (capped at `'high'`)
- Other thinking models: budget tokens only
Anthropic `modelConfig.effort` can be set directly on a request. Fast mode and
task budgets are Anthropic-only opt-ins; `taskBudget.total` must be at least
20,000 tokens.
```typescript
const res = await claude.chat({
chatPrompt: [{ role: 'user', content: 'Review this migration plan.' }],
modelConfig: {
effort: 'xhigh',
speed: 'fast',
taskBudget: { type: 'tokens', total: 64_000 },
},
});
```
### Custom Thinking Levels
```typescript
const claude = ai({
name: 'anthropic',
apiKey: '...',
config: {
model: AxAIAnthropicModel.Claude48Opus,
thinkingTokenBudgetLevels: {
minimal: 2048,
low: 8000,
medium: 16000,
high: 25000,
highest: 40000,
},
effortLevelMapping: {
minimal: 'low',
low: 'medium',
medium: 'high',
high: 'high',
highest: 'max',
},
},
});
```
## Embeddings
```typescript
const { embeddings } = await llm.embed({
texts: ['hello', 'world'],
embedModel: 'text-embedding-005',
});
```
## Context Caching
```typescript
const result = await gen.forward(llm, { code, language }, {
mem,
sessionId: 'code-review-session',
contextCache: {
ttlSeconds: 3600,
cacheBreakpoint: 'after-examples',
},
});
```
Breakpoint values: `'system'` | `'after-functions'` | `'after-examples'`
Provider behavior:
- Google Gemini: explicit caching with cache resource ID and auto TTL refresh;
failed refreshes recreate or fall back uncached, and rejected Ax-managed
caches retry once without the cache
- Anthropic: implicit via `cache_control` markers
### External Registry (serverless)
```typescript
const accountId = getRequiredAccountId();
const registry: AxContextCacheRegistry = {
get: async (key) => {
const value = await redis.get(`context-cache:${accountId}:${key}`);
return value ? JSON.parse(value) : undefined;
},
set: async (key, entry) => {
const ttl = Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000));
await redis.set(
`context-cache:${accountId}:${key}`,
JSON.stringify(entry),
{ ex: ttl }
);
},
};
```
Ax registry keys are content-based and are not account-scoped. Require a stable
tenant/account namespace when cross-account cache sharing is unsafe; do not
silently fall back to a global namespace.
## AWS Bedrock
```typescript
import { AxAIBedrock, AxAIBedrockModel } from '@ax-llm/ax-ai-aws-bedrock';
const bedrock = new AxAIBedrock({
region: 'us-east-2',
fallbackRegions: ['us-west-2'],
config: { model: AxAIBedrockModel.ClaudeOpus45 },
});
```
## Vercel AI SDK Integration
```typescript
import { generateText } from 'ai';
import { ai } from '@ax-llm/ax';
import { AxAIProvider } from '@ax-llm/ax-ai-sdk-provider';
const axAI = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY ?? '',
});
const model = new AxAIProvider(axAI);
const result = await generateText({
model,
prompt: 'Hello!',
});
```
## MCP + AxJSRuntime
```typescript
import { AxMCPClient } from '@ax-llm/ax';
import { axCreateMCPStdioTransport } from '@ax-llm/ax-tools';
const transport = axCreateMCPStdioTransport({
command: 'npx',
args: ['-y', '@anthropic/mcp-server-filesystem'],
});
const client = new AxMCPClient(transport);
```
For server notifications, call `client.startListening({ signal, onError })` or
attach the client through `AxMCPEventSource`. The event adapter is preferred
for autonomous work because protocol callbacks only enqueue; explicit routes
decide whether to observe, invalidate, resume, or wake.
For signed UCP lifecycle requests, mount
`AxUCPWebhookEventSource.ingest(request)` in application-owned HTTP hosting.
Signature, profile, digest, freshness, and replay verification completes before
the event runtime sees the request.
## Critical Rules
- Use `ai()` factory for all providers.
- Provider names: `'openai'`, `'openai-responses'`, `'anthropic'`, `'google-gemini'`, `'azure-openai'`, `'mistral'`, `'cohere'`, `'deepseek'`, `'reka'`, `'grok'`
- Thinking constraints on Anthropic: every adaptive-thinking model omits
`temperature`, `topP`, and `topK`; older thinking models ignore `temperature` and `topK`, with
`topP` only sent if >= 0.95.
- Bedrock uses `new AxAIBedrock()`, not `ai()`.
- Vercel AI SDK uses `AxAIProvider` wrapper.
## Examples
Fetch these for full working code:
- [Embeddings](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/embed.ts) — embedding generation
- [Anthropic Thinking](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/anthropic-thinking-function.ts) — extended thinking with functions
- [Anthropic Thinking Separation](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/anthropic-thinking-separation.ts) — thinking separation
- [Anthropic Web Search](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/anthropic-web-search.ts) — Anthropic web search
- [OpenAI Web Search](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/openai-web-search.ts) — OpenAI web search
- [OpenAI Responses](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/openai-responses.ts) — OpenAI responses API
- [o3 Reasoning](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/reasoning-o3-example.ts) — o3 reasoning
- [Gemini Context Cache](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/gemini-context-cache.ts) — Gemini context caching
- [Gemini Files](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/gemini-file-support.ts) — Gemini file handling
- [Grok Live Search](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/grok-live-search.ts) — Grok live search
- [OpenAI-Compatible](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/openai-compatible.ts) — custom OpenAI-compatible base URL
- [Vertex AI Auth](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/vertex-auth-example.ts) — Vertex AI authentication
- [MCP Stdio](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/mcp-client-memory.ts) — MCP stdio transport
- [MCP HTTP](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/mcp-client-pipedream.ts) — MCP HTTP transport
- [Telemetry](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/telemetry.ts) — OpenTelemetry tracing
- [Multi-Modal](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/multi-modal.ts) — image handling
## Do Not Generate
- Do not use `new AxAIOpenAI(...)` or similar class constructors for standard providers; use `ai()`.
- Do not hardcode provider class names when `ai({ name: ... })` covers the provider.
- Do not mix `thinkingTokenBudget` with explicit `temperature` on Anthropic thinking models.
- Do not use `ai()` for AWS Bedrock; use `new AxAIBedrock()`.
- Do not omit `resourceName` and `deploymentName` for Azure OpenAI.

View File

@@ -1,373 +0,0 @@
---
name: ax-audio
description: This skill helps an LLM generate correct audio code with @ax-llm/ax. Use when the user asks about ai.transcribe(), ai.speak(), signature audio inputs or outputs, agent audio behavior, .chat() conversational audio, OpenAI audio or realtime models, Gemini Live native audio, Grok Voice Agent models, voices, formats, transcripts, or how audio fits with structured outputs.
version: "23.0.9"
---
# Audio I/O Codegen Rules (@ax-llm/ax)
Use this skill for audio in Ax. Pick the smallest audio surface that matches the job:
- Use `ai.transcribe(...)` for batch speech-to-text.
- Use `ai.speak(...)` for batch text-to-speech.
- Use `speech:audio` signature outputs for structured programs that should return synthesized audio artifacts.
- Use `.chat()` audio config for conversational or realtime audio turns.
## Core Rules
- Input `:audio` is an audio input value: `{ data, format?, mimeType?, sampleRate?, channels? }`.
- Output `:audio` is a scripted audio artifact. The model returns plain text for that field; Ax synthesizes it after structured output parsing.
- Output audio JSON schema is model-facing `string`, not a binary object.
- Agents transcribe input audio fields before planner/executor/responder stages by default, so agent stages see text instead of base64 audio.
- Realtime and conversational audio still use `.chat()` and `modelConfig.audio`.
- Batch signature audio artifacts use forward-time `speech` options, not `modelConfig.audio`.
## Direct Batch APIs
```typescript
import { ai } from '@ax-llm/ax';
const llm = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY! });
const transcript = await llm.transcribe({
audio: { data: base64Wav, format: 'wav' },
model: 'gpt-4o-mini-transcribe',
language: 'en',
prompt: 'Product support call',
});
const speech = await llm.speak({
text: transcript.text,
model: 'gpt-4o-mini-tts',
voice: 'alloy',
format: 'mp3',
});
console.log(transcript.text);
console.log(speech.data);
console.log(speech.transcript);
```
Providers without the requested batch audio capability throw `AxMediaNotSupportedError`.
## Signature Audio Artifacts
```typescript
import { ai, ax } from '@ax-llm/ax';
const llm = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY! });
const say = ax('question:string -> speech:audio, summary:string');
const result = await say.forward(
llm,
{ question: 'Explain retries in one sentence.' },
{
speech: {
speak: { voice: 'alloy', format: 'mp3' },
fields: {
speech: { voice: 'alloy' },
},
},
}
);
console.log(result.summary);
console.log(result.speech.data);
console.log(result.speech.mimeType);
console.log(result.speech.transcript);
```
The model emits a text script for `speech`; Ax replaces it with `AxChatAudioOutput` after result selection. If the field already contains an audio artifact with `{ data }` or `{ id }`, Ax leaves it alone.
## Agent Audio Inputs
```typescript
import { agent, ai } from '@ax-llm/ax';
const llm = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY! });
const voiceAgent = agent(
'recording:audio, question:string -> speech:audio, summary:string',
{
agentIdentity: {
name: 'Voice Assistant',
description: 'Answers spoken requests with spoken and written output',
},
contextFields: [],
}
);
const result = await voiceAgent.forward(
llm,
{
recording: { data: base64Wav, format: 'wav' },
question: 'What should I do next?',
},
{
speech: {
transcribe: { model: 'gpt-4o-mini-transcribe' },
speak: { voice: 'alloy', format: 'mp3' },
},
}
);
console.log(result.summary);
console.log(result.speech.data);
```
The agent runtime transcribes `recording` first and passes the transcript through the internal agent stages. Use direct `ax(...)` or `.chat()` when you specifically want native audio understanding in the model call.
## Conversational `.chat()` Audio
Use `modelConfig.audio` for conversational audio turns where audio is part of the chat response instead of a structured signature field.
```typescript
const res = await llm.chat({
chatPrompt: [{ role: 'user', content: 'Say hello out loud.' }],
modelConfig: {
audio: { output: { enabled: true, voice: 'alloy', format: 'wav' } },
},
});
console.log(res.results[0]?.content);
console.log(res.results[0]?.audio?.data);
console.log(res.results[0]?.audio?.transcript);
```
## Config Shape
```typescript
type AxAudioFormat =
| 'wav'
| 'mp3'
| 'flac'
| 'opus'
| 'aac'
| 'pcm16'
| 'pcm'
| 'ogg'
| 'raw'
| 'mulaw'
| 'ulaw'
| 'alaw';
type AxSpeechConfig = {
transcribe?: {
model?: string;
language?: string;
prompt?: string;
};
speak?: {
model?: string;
voice?: string;
format?: AxAudioFormat;
};
fields?: Record<
string,
{
model?: string;
voice?: string;
format?: AxAudioFormat;
}
>;
};
```
## OpenAI Defaults
Use `axAIOpenAIAudioDefaultConfig()` for OpenAI request-based audio chat:
- model: `gpt-audio-mini`
- output enabled
- voice: `alloy`
- output format: `wav`
- transcript enabled
- streaming disabled by default
- audio input formats: `wav`, `mp3`
- audio output formats: `wav`, `mp3`, `flac`, `opus`, `aac`, `pcm16`
```typescript
import { ai, axAIOpenAIAudioDefaultConfig } from '@ax-llm/ax';
const openai = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
config: axAIOpenAIAudioDefaultConfig(),
});
const res = await openai.chat({
chatPrompt: [
{
role: 'user',
content: [
{ type: 'text', text: 'What is in this recording?' },
{ type: 'audio', data: base64Wav, format: 'wav' },
],
},
],
});
console.log(res.results[0]?.content);
console.log(res.results[0]?.audio?.data);
```
Use `axAIOpenAIRealtimeDefaultConfig()` for OpenAI realtime speech-to-speech:
- model: `gpt-realtime-2`
- output enabled
- voice: `marin`
- output format: `pcm16`
- input default: `audio/pcm`, mono, 24000 Hz
- turn timeout: `30000`
- streaming disabled by default
Use `axAIOpenAIRealtimeTranscriptionDefaultConfig()` for realtime transcript deltas:
- model: `gpt-realtime-whisper`
- input default: `audio/pcm`, mono, 24000 Hz
- output audio disabled; transcript text is returned on `content`
Realtime models use a one-turn WebSocket call under `.chat()`. In Node, pass a WebSocket constructor through request options:
```typescript
import WebSocket from 'ws';
import { ai, axAIOpenAIRealtimeDefaultConfig } from '@ax-llm/ax';
const openai = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
config: axAIOpenAIRealtimeDefaultConfig(),
});
const stream = await openai.chat(
{
chatPrompt: [{ role: 'user', content: 'Say hello out loud.' }],
},
{ stream: true, webSocket: WebSocket }
);
```
For follow-up turns, keep the assistant audio reference in history:
```typescript
await openai.chat({
chatPrompt: [
{ role: 'assistant', audio: { id: previousAudioId } },
{ role: 'user', content: 'Repeat that more slowly.' },
],
});
```
## Gemini Live Defaults
Use `axAIGoogleGeminiLiveAudioDefaultConfig()` for Gemini native audio:
- model: `gemini-2.5-flash-native-audio-preview-12-2025`
- output enabled
- voice: `Kore`
- output format: `pcm16`
- output sample rate: `24000`
- input default: `audio/pcm;rate=16000`, mono
- transcript enabled
- turn timeout: `30000`
- streaming disabled by default
```typescript
import { ai, axAIGoogleGeminiLiveAudioDefaultConfig } from '@ax-llm/ax';
const gemini = ai({
name: 'google-gemini',
apiKey: process.env.GOOGLE_APIKEY!,
config: axAIGoogleGeminiLiveAudioDefaultConfig(),
});
const res = await gemini.chat({
chatPrompt: [
{
role: 'user',
content: [
{ type: 'text', text: 'Answer this spoken question.' },
{
type: 'audio',
data: base64Pcm16,
format: 'pcm16',
sampleRate: 16000,
channels: 1,
},
],
},
],
});
console.log(res.results[0]?.content);
console.log(res.results[0]?.audio?.data);
```
Gemini Live uses a one-turn WebSocket call under `.chat()`. It expects PCM input for native audio turns; use `format: 'pcm16'` or `mimeType: 'audio/pcm;rate=16000'`.
## Grok Voice Defaults
Use `axAIGrokVoiceDefaultConfig()` for xAI Grok Voice Agent:
- model: `grok-voice-think-fast-1.0`
- output enabled
- voice: `eve`
- output format: `pcm16`
- output sample rate: `24000`
- input default: `audio/pcm`, mono, 24000 Hz
- transcript enabled
- turn timeout: `30000`
- streaming disabled by default
```typescript
import WebSocket from 'ws';
import { ai, axAIGrokVoiceDefaultConfig } from '@ax-llm/ax';
const grok = ai({
name: 'grok',
apiKey: process.env.GROK_API_KEY!,
config: axAIGrokVoiceDefaultConfig(),
});
const res = await grok.chat(
{
chatPrompt: [{ role: 'user', content: 'Say hello out loud.' }],
},
{ webSocket: WebSocket }
);
console.log(res.results[0]?.content);
console.log(res.results[0]?.audio?.data);
```
Grok Voice uses a one-turn WebSocket call under `.chat()`. It expects PCM input for spoken input turns; use `format: 'pcm16'` or `mimeType: 'audio/pcm'`.
## Streaming Audio
OpenAI audio chat, OpenAI Realtime, Gemini Live, and Grok Voice all default to non-streaming, but each can stream deltas when you pass `{ stream: true }`.
```typescript
const stream = await llm.chat(
{
chatPrompt: [{ role: 'user', content: 'Say hello.' }],
},
{ stream: true }
);
for await (const chunk of stream) {
const audio = chunk.results[0]?.audio;
if (audio?.isDelta) {
playAudioChunk(audio.data);
}
}
```
## Structured Outputs
Use signature audio outputs for structured speech artifacts:
```typescript
const gen = ax('question:string -> answer:string, speech:audio');
```
Use `.chat()` audio when the response itself is a conversational audio turn. Do not combine `.chat()` audio output with provider-native structured response formats unless that provider explicitly supports the combination.

View File

@@ -1,145 +0,0 @@
---
name: ax-event-runtime
description: Use AxEventRuntime to ingest events, explicitly wake or resume AxGen, AxAgent, and AxFlow, persist state and results, and route outputs safely.
---
# Ax Event Runtime
Use this skill when an Ax program should react to notifications, webhooks,
timers, queues, task completion, or application events.
## Mental Model
```text
source -> inbox -> route -> target -> stored run -> sink
```
Sources never call an Ax program directly. A route must explicitly choose
`observe`, `invalidate`, `wake`, or `resume`. Only the last two invoke a model.
## Minimal Pattern
```ts
const source = new AxPushEventSource('application');
const target = eventTarget('triage')
.program(triageAgent)
.ai(llm)
.input((input) => input.field('incident', eventPath.data()))
.sink({ id: 'result', write: saveResult })
.build();
const events = eventRuntime({
sources: [source],
routes: [
eventRoute('incident-created')
.types('incident.created')
.wake(target)
.build(),
],
});
await events.start();
await source.publish({ event, identity, trust: 'authenticated' });
```
## Rules
- Supply identity from authenticated adapter state, never from event data.
- Treat events without verified identity as anonymous and untrusted.
- Map event data into signature inputs; do not synthesize a user message.
- Use `eventPath.data('field')` and other segment-safe selectors. Do not use
dotted JSONPath strings or repurpose `s()` as a mapping language.
- Use `.project(path)` only for same-name signature projection. Explicit
`.field()` mappings override projection; missing or invalid signature inputs
dead-letter before model invocation.
- Use `eventInput().project(...).field(...)` when a declarative mapping should
be callback-free and reusable, then pass that plan to `.input()`,
`.wakeInput()`, or `.resumeInput()`.
- Callback `mapInput` is an escape hatch, not a validation bypass: its result is
normalized to the program signature and mapper failures dead-letter before
invocation.
- Use `.wakeInput()` and `.resumeInput()` when the two actions need different
contracts. Neither action silently uses the other action's mapping.
- Use `observe` for progress/logs and `invalidate` for catalog changes.
- Use `resume` only with an owned continuation correlation key.
- Use `createProgram(instance)` for stateful multi-tenant Agents.
- Declare `retrySafety: 'idempotent'` only when stable delivery keys protect
every possible side effect.
- Persist outputs before final sink delivery; redrive sink failures separately.
- Use `debounceMs` and `coalesce: 'latest'` only when replacing intermediate
events is part of the route's declared policy.
- Observe source failures with `onSourceError`.
- The in-memory store is volatile and single-process.
- For cooperating Node processes on one local disk, use
`AxSQLiteEventStore` from `@ax-llm/ax-tools/event/sqlite` with explicit
retention and `coordination: 'multi-worker'`. Never recommend SQLite on a
network filesystem.
- Close the runtime and caller-owned protocol clients explicitly.
- Fan out to several Agents with several matching routes, not a multi-target
route. This preserves independent authorization, ordering, retries, and runs.
## Continuation Pattern
```ts
eventContext.registerContinuation({
correlation: [{ kind: 'task', value: taskId }],
expiresAt,
});
```
Route progress to `observe`. Route `input_required`, completed, failed, or
cancelled task events to `resume` when the owning program must run again.
## MCP Adapter
Use `ax-mcp` for client construction, transports, authentication, catalogs,
subscriptions, tasks, and MCP-specific security policy. This skill owns the
generic inbox, routing, continuation, store, and sink behavior.
Use `client.inspectCatalog()` to discover server-owned tools, prompts, concrete
resources, and URI templates from only the endpoint. Then use
`AxMCPEventSource({ client, resourceSubscriptions, identity, trust })` with an
explicit none/all/URI/selector policy. Omitted policy subscribes to no
resources. Templates are never expanded automatically. Managed sources diff
catalog changes, restore current logical ownership on reconnect, and release
only their own subscriptions on close. Identity must come from the
application's authenticated client or token mapping; a bare MCP session is
anonymous. Add `...axMCPEventRoutes({ client })` for catalog invalidation,
progress/log observation, and task resume. Resource notifications never get an
implicit wake route. See `docs/MCP_SUBSCRIPTIONS.md`.
## UCP Adapter
Use `AxUCPWebhookEventSource({ client, identity })` inside an application-owned
HTTP handler, then call `source.ingest(request)`. Verification of the signer
profile, RFC 9421 signature, digest, freshness window, key rotation, and replay
key completes before enqueue. Resolve tenant/account identity from application
state after verification; do not copy identity from the business payload.
Generated Python, Java, C++, Go, and Rust packages expose the same Core-owned
single-worker event state machine plus functioning inline lifecycle dispatch,
continuations, state restoration, cancellation, persisted outputs, isolated
sink redrive, signature-aware path/input/target/route builders, and host-owned
source, sink, clock, and store boundaries. Generated targets use the host
signature plus a typed invocation callback when no common object-safe program
interface exists. Do not claim persistent multi-worker support from
`axevent.single-worker` alone.
Generated runtimes do not create worker threads. `publish()` drains work due at
`clock.now()`. Hosts use `nextDueAt()` to schedule `runDue()` for debounce,
retry, and continuation expiry; `redrive()` is due immediately. Manual clocks
make these transitions deterministic. Generated in-memory stores enforce
10,000 pending deliveries, 64 MiB queued data, 1 MiB per envelope, and a
five-second publication wait.
## Testing
Use `AxManualEventClock`, `AxInMemoryEventStore`, deterministic event IDs, and
an output-capturing sink. Assert that unmatched or observe-only events never
invoke the program, tenant scopes do not collide, outputs exist before sinks,
and uncertain side effects become `outcome_unknown`.
Persistent store implementations must pass
`runAxEventStoreConformance(createStore, { clock })`. A store must not advertise
multi-worker capability without the conformance marker checked by runtime
startup.

View File

@@ -1,682 +0,0 @@
---
name: ax-flow
description: This skill helps an LLM generate correct AxFlow workflow code using @ax-llm/ax. Use when the user asks about flow(), AxFlow, workflow orchestration, parallel execution, DAG workflows, conditional routing, map/reduce patterns, or multi-node AI pipelines.
version: "23.0.9"
---
# AxFlow Codegen Rules (@ax-llm/ax)
Use this skill to generate `AxFlow` workflow code. Prefer short, modern, copyable patterns. Do not write tutorial prose unless the user explicitly asks for explanation.
## Use These Defaults
- Use `flow()` factory, not `new AxFlow()`.
- Import: `import { ai, flow, f } from '@ax-llm/ax';`
- `autoParallel: true` is the default; independent executes and derives run in parallel when their metadata reads/writes are known and non-conflicting.
- Node results are stored as `${nodeName}Result` in state.
- Always define `.node()` before `.execute()` for that node.
- Use `.returns()` (or `.r()`) as the last step to lock the output type.
- Use descriptive node names: `documentSummarizer`, not `proc1`.
- Use descriptive field names: `userInput`, `responseText`, not `text`, `result`.
## Critical Rules
- Use `flow()` factory syntax for new code.
- Node results in state follow the pattern `state.${nodeName}Result.${fieldName}`.
- `.execute()` maps current state to node inputs; `.map()` transforms state without AI calls.
- `.returns()` maps final state to the flow output type.
- Always define nodes before executing them; reversed order throws at runtime.
- Keep state flat; avoid deep nesting in `.map()`.
- Ensure loop conditions can change to avoid infinite loops.
- Structure independent executes to maximize safe auto-parallelization.
- Use `flow<InputType, OutputType>()` for typed flows.
- Aliases: `.n()` = `.node()`, `.nx()` = `.nodeExtended()`, `.m()` = `.map()`, `.r()` = `.returns()`.
## Canonical Pattern
```typescript
import { ai, flow } from '@ax-llm/ax';
const llm = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY! });
const wf = flow<{ userInput: string }, { responseText: string }>()
.node('testNode', 'userInput:string -> responseText:string')
.execute('testNode', (state) => ({ userInput: state.userInput }))
.returns((state) => ({ responseText: state.testNodeResult.responseText }));
const result = await wf.forward(llm, { userInput: 'Hello world' });
console.log(result.responseText);
```
## Factory Options
```typescript
// Basic
const wf = flow();
// With options
const wf = flow({ autoParallel: false });
// Typed
const wf = flow<InputType, OutputType>();
// Typed with options
const wf = flow<InputType, OutputType>({ autoParallel: true, batchSize: 5 });
```
## State Evolution
State grows with each executed node. Results are stored as `${nodeName}Result`:
```typescript
// Initial state: { userInput: 'Hello' }
flow.execute('processor', (state) => ({ input: state.userInput }));
// State: { userInput: 'Hello', processorResult: { output: '...' } }
flow.execute('analyzer', (state) => ({ text: state.processorResult.output }));
// State: { ..., analyzerResult: { sentiment: '...', confidence: 0.8 } }
```
## Node Definition
```typescript
// String signature (creates AxGen automatically)
flow.node('processor', 'input:string -> output:string');
// Multiple outputs
flow.node('analyzer', 'text:string -> sentiment:string, confidence:number');
// Array outputs
flow.node('extractor', 'documentText:string -> entities:string[]');
// Short alias
flow.n('processor', 'input:string -> output:string');
```
### Rich Node Contracts (String Grammar)
Node signatures accept the full extended string grammar — constraint bags, class decisions, optional fields, and nested objects (full modifier table in the ax-signature skill):
```typescript
flow
.node('triage', 'ticketText:string -> ticketClass:class "bug, billing, question", severityScore:number(min 1, max 5)')
.node('draft', 'ticketText:string, ticketClass:string, severityScore:number -> replyText:string(max 400)')
.node('audit', 'replyText:string -> approved:boolean, flaggedSpans:object{ spanText:string, reasonNote:string }[]');
```
- `class` is output-only: a downstream node consuming the decision declares it `:string`.
- Optional marks go on the name (`note?:string`), never after the type.
- `toString()` serializes these contracts losslessly into `%%ax` directives, so rich contracts survive the diagram round-trip.
## Extended Nodes (nx)
Add fields to a base signature without rewriting it:
```typescript
import { f, flow } from '@ax-llm/ax';
// Chain-of-thought reasoning
flow.nx('reasoner', 'question:string -> answer:string', {
prependOutputs: [
{ name: 'reasoning', type: f.internal(f.string('Step-by-step reasoning')) },
],
});
// Add confidence scoring
flow.nx('analyzer', 'input:string -> result:string', {
appendOutputs: [{ name: 'confidence', type: f.number('Confidence 0-1') }],
});
// Add optional context input
flow.nx('processor', 'query:string -> response:string', {
appendInputs: [{ name: 'context', type: f.optional(f.string('Extra context')) }],
});
```
Extension options: `prependInputs`, `appendInputs`, `prependOutputs`, `appendOutputs`.
## Execute With Input Mapping
```typescript
flow.execute('summarizer', (state) => ({ documentText: state.document }));
// With AI override (use a different model for this node)
flow.execute('processor', (state) => ({ input: state.data }), { ai: alternativeAI });
```
## Map (State Transformation)
Use `map()` for data shaping without AI calls:
```typescript
// Sync
flow.map((state) => ({ ...state, upperText: state.rawText.toUpperCase() }));
// Async
flow.map(async (state) => {
const data = await fetchFromAPI(state.query);
return { ...state, enrichedData: data };
});
// Parallel async transforms
flow.map([
async (state) => ({ ...state, result1: await api1(state.data) }),
async (state) => ({ ...state, result2: await api2(state.data) }),
], { parallel: true });
```
## Returns (Final Output)
```typescript
const wf = flow<{ input: string }>()
.map((state) => ({ ...state, upper: state.input.toUpperCase(), len: state.input.length }))
.returns((state) => ({ upper: state.upper, isLong: state.len > 20 }));
// Result is typed as { upper: string; isLong: boolean }
const result = await wf.forward(llm, { input: 'test' });
```
## Sequential Processing
```typescript
const wf = flow<{ input: string }, { finalResult: string }>()
.node('step1', 'input:string -> intermediate:string')
.node('step2', 'intermediate:string -> output:string')
.execute('step1', (state) => ({ input: state.input }))
.execute('step2', (state) => ({ intermediate: state.step1Result.intermediate }))
.returns((state) => ({ finalResult: state.step2Result.output }));
```
## Auto-Parallel Execution
Independent execute steps run in parallel automatically (`autoParallel: true` by default) when their metadata reads/writes are known and non-conflicting:
```typescript
const wf = flow<{ text: string }, { combined: string }>()
.node('sentimentAnalyzer', 'text:string -> sentiment:string')
.node('topicExtractor', 'text:string -> topics:string[]')
.node('entityRecognizer', 'text:string -> entities:string[]')
// These three run in parallel (all depend only on state.text)
.execute('sentimentAnalyzer', (state) => ({ text: state.text }))
.execute('topicExtractor', (state) => ({ text: state.text }))
.execute('entityRecognizer', (state) => ({ text: state.text }))
// This waits for all three
.returns((state) => ({
combined: JSON.stringify({
sentiment: state.sentimentAnalyzerResult.sentiment,
topics: state.topicExtractorResult.topics,
entities: state.entityRecognizerResult.entities,
}),
}));
// Inspect execution plan
const plan = wf.getExecutionPlan();
console.log(plan.parallelGroups, plan.maxParallelism);
```
Planner rules:
- Independent `.execute()` and `.derive()` steps may parallelize.
- `.map()`, `.returns()`, `.branch()`, `.while()`, `.feedback()`, and explicit `.parallel()` are barriers.
- Branch, while, and feedback bodies still use the same planner internally.
- Use `autoParallel: false` when you need strict sequential execution.
Disable auto-parallel:
```typescript
const wf = flow({ autoParallel: false });
// or per execution:
await wf.forward(llm, input, { autoParallel: false });
```
## Conditional Branching
```typescript
const wf = flow<{ query: string; expertMode: boolean }, { response: string }>()
.node('simple', 'query:string -> response:string')
.node('expert', 'query:string -> response:string')
.branch((state) => state.expertMode)
.when(true)
.execute('expert', (state) => ({ query: state.query }))
.when(false)
.execute('simple', (state) => ({ query: state.query }))
.merge()
.returns((state) => ({
response: state.expertResult?.response ?? state.simpleResult?.response,
}));
```
After `.merge()`, only the taken branch's result exists; use optional chaining (`?.`) on untaken branch results.
## While Loops
```typescript
const wf = flow<{ content: string }, { finalContent: string }>()
.node('processor', 'content:string -> processedContent:string')
.node('qualityChecker', 'content:string -> qualityScore:number')
.map((state) => ({ currentContent: state.content, iteration: 0, qualityScore: 0 }))
.while((state) => state.iteration < 3 && state.qualityScore < 0.8)
.map((state) => ({ ...state, iteration: state.iteration + 1 }))
.execute('processor', (state) => ({ content: state.currentContent }))
.execute('qualityChecker', (state) => ({
content: state.processorResult.processedContent,
}))
.map((state) => ({
...state,
currentContent: state.processorResult.processedContent,
qualityScore: state.qualityCheckerResult.qualityScore,
}))
.endWhile()
.returns((state) => ({ finalContent: state.currentContent }));
```
Rules:
- Every `.while()` needs a matching `.endWhile()`.
- Ensure the loop condition can change to avoid infinite loops.
## Feedback Loops (label/feedback)
```typescript
const wf = flow<{ prompt: string }, { result: string }>()
.node('gen', 'prompt:string -> result:string, quality:number')
.map((state) => ({ ...state, tries: 0 }))
.label('retry')
.map((state) => ({ ...state, tries: state.tries + 1 }))
.execute('gen', (state) => ({ prompt: state.prompt }))
.feedback((state) => state.genResult.quality < 0.9 && state.tries < 3, 'retry')
.returns((state) => ({ result: state.genResult.result }));
```
Rules:
- Define the label before referencing it in `.feedback()`.
- Always include a max-iteration guard to avoid infinite loops.
## Explicit Parallel Sub-Flows
```typescript
flow
.parallel([
(sub) => sub.execute('analyzer1', (state) => ({ text: state.input })),
(sub) => sub.execute('analyzer2', (state) => ({ text: state.input })),
(sub) => sub.execute('analyzer3', (state) => ({ text: state.input })),
])
.merge('combinedResults', (r1, r2, r3) => ({
a1: r1.analyzer1Result.analysis,
a2: r2.analyzer2Result.analysis,
a3: r3.analyzer3Result.analysis,
}));
```
## Derive (Batch/Array Processing)
```typescript
const wf = flow<{ items: string[] }, { processed: string[] }>({ batchSize: 3 })
.derive('processed', 'items', (item, index) => `processed-${item}-${index}`, {
batchSize: 2,
});
```
## Dynamic AI Context (Multi-Model)
Route nodes to different AI providers:
```typescript
const fast = ai({ name: 'openai', apiKey: '...', config: { model: 'gpt-5.4-mini' } });
const smart = ai({ name: 'anthropic', apiKey: '...' });
const wf = flow<{ text: string }, { out: string }>()
.node('draft', 'text:string -> out:string')
.node('refine', 'text:string -> out:string')
.execute('draft', (state) => ({ text: state.text }), { ai: fast })
.execute('refine', (state) => ({ text: state.draftResult.out }), { ai: smart })
.returns((state) => ({ out: state.refineResult.out }));
```
## Description and toFunction
```typescript
const wf = flow<{ userQuestion: string }, { responseText: string }>()
.node('qa', 'userQuestion:string -> responseText:string')
.execute('qa', (state) => ({ userQuestion: state.userQuestion }))
.returns((state) => ({ responseText: state.qaResult.responseText }))
.description('Question Answerer', 'Answers user questions concisely.');
const fn = wf.toFunction();
// fn.name, fn.parameters (JSON Schema), fn.func
```
## Instrumentation (Tracing)
```typescript
import { ai, flow } from '@ax-llm/ax';
import { context, trace } from '@opentelemetry/api';
const tracer = trace.getTracer('axflow');
const llm = ai({ name: 'openai', apiKey: '...' });
const wf = flow<{ userQuestion: string }>()
.node('summarizer', 'documentText:string -> summaryText:string')
.execute('summarizer', (s) => ({ documentText: s.userQuestion }))
.returns((s) => ({ answer: s.summarizerResult.summaryText }));
const result = await wf.forward(llm, { userQuestion: 'hi' }, {
tracer,
traceContext: context.active(),
});
```
Flow tracing also respects live app-wide defaults:
```typescript
import { axGlobals } from '@ax-llm/ax';
import { metrics } from '@opentelemetry/api';
axGlobals.tracer = tracer;
axGlobals.meter = metrics.getMeter('axflow');
const result = await wf.forward(llm, { userQuestion: 'hi' });
```
Rules:
- `wf.forward(..., { tracer, meter })` overrides flow defaults and `axGlobals`.
- Constructor/factory flow defaults override `axGlobals`.
- If no local tracer or meter is provided, `AxFlow` reads current `axGlobals.tracer` and `axGlobals.meter`, creates a parent flow span, and propagates tracer/meter plus trace context to node forwards.
- `axGlobals.abortSignal` is merged with flow-level abort signals.
## Program IDs and Demos
```typescript
const wf = flow<{ input: string }>()
.node('summarizer', 'text:string -> summary:string')
.node('classifier', 'text:string -> category:string');
// Discover program IDs
console.log(wf.namedPrograms());
// [{ id: 'root.summarizer', ... }, { id: 'root.classifier', ... }]
// Set demos (TypeScript catches typos)
wf.setDemos([{ programId: 'root.summarizer', traces: [] }]);
// Apply optimization
wf.applyOptimization(optimizedProgram);
```
For tuning a flow, use top-level `optimize(wf, train, metric, options)` from the
`ax-gepa` skill. There is no separate `flow.optimize(...)` helper.
## Chat Logs
`AxFlow.getChatLog()` returns a flat `readonly AxChatLogEntry[]` after `forward()`. Each child-node entry is tagged with `entry.name` so callers can filter by node:
```typescript
const log = wf.getChatLog();
for (const entry of log) {
console.log(entry.name, entry.model);
}
```
## Error Handling
```typescript
try {
const result = await wf.forward(llm, input);
} catch (error) {
console.error('Flow execution failed:', error);
}
```
Common errors:
- `"Node 'x' not found"` -- define `.node()` before `.execute()`.
- `"endWhile() without matching while()"` -- every `.while()` needs `.endWhile()`.
- `"when() without matching branch()"` -- `.when()` must be inside `.branch()`/`.merge()`.
- `"merge() without matching branch()"` -- every `.branch()` needs `.merge()`.
- `"Label 'x' not found"` -- define `.label()` before `.feedback()` references it.
## Native MCP/UCP
Use `ax-mcp` for MCP client construction, transport/authentication policy,
subscriptions, tasks, event routing, and replay. This section covers how Flow
inherits and coordinates the resulting live execution context.
Set `mcp`/`ucp` on the flow or a node. Sequential nodes reuse sessions; parallel nodes multiplex through each client's concurrency policy. Branch cancellation and flow aborts propagate to outstanding requests and newly created remote tasks. Structured protocol values stay structured in flow state.
```typescript
const wf = flow({ mcp: [inventory], ucp: [merchant] })
.node('lookup', lookupProgram)
.node('checkout', checkoutProgram, { mcpInheritance: ['merchant'] });
```
## Mermaid Source (Author or Serialize Flows)
A whole flow can be written as (or exported to) a mermaid flowchart. Pass the
diagram string straight to `flow()` — a string argument compiles the AxFlow
mermaid dialect into a runnable flow (an options object still constructs an
empty builder). `String(wf)` / `wf.toString()` renders any flow back, so
`flow(String(wf))` round-trips.
```typescript
import { flow } from '@ax-llm/ax';
const wf = flow<{ documentText: string }, { finalReport: string }>(`
flowchart TD
%%ax summarize: documentText:string -> summaryText:string(max 500)
%%ax check: summaryText:string -> verdict:class "pass, fail", note?:string
%%ax format: summaryText:string, note?:string -> finalReport:string
summarize[Summarize document] --> check{verdict}
check -->|pass| format
check -->|fail, max 3| summarize
`);
const { finalReport } = await wf.forward(llm, { documentText });
console.log(String(wf)); // render back to the same dialect
```
Dialect:
- `%%ax nodeId: <signature>` comment directives carry node contracts (mermaid renderers ignore them); the full string-signature grammar applies (`?` optional on the name, constraint bags, `object{ ... }`).
- Data auto-wires by field name: each node input binds to the nearest upstream node that outputs that field; a field no node produces becomes a flow input.
- A diamond `nodeId{field}` names a `class` decision; its labeled out-edges (`-->|pass|`) become branches. A back-edge is a loop: `-->|label, max N|` is feedback, `-->|while cond, max N|` is a while loop.
Render options and bindings:
- `wf.toString({ direction: 'LR' })` when you need render options; bare `String(wf)` uses defaults (`flowchart TD`).
- `bindings` supplies closures the dialect can't inline: `{ nodes: { normalize: (s) => ({...}) }, conditions: { keepGoing: (s) => ... } }` for map steps and `while` conditions.
### Flow Gallery
Every diagram below compiles with `flow(text)` as written (the while loop additionally needs its `conditions` binding).
Linear pipeline — three nodes auto-wired by field name:
```text
flowchart TD
%%ax extract: contractText:string -> parties:string[], effectiveDate?:string(format date)
%%ax summarize: contractText:string, parties:string[] -> summaryText:string(max 300)
%%ax redline: summaryText:string -> riskNotes:string(item "one risk")[]
extract --> summarize --> redline
```
Decision branch — a class diamond routes to per-branch responders, then re-joins:
```text
flowchart TD
%%ax classify: requestText:string -> routeClass:class "support, sales"
%%ax supportReply: requestText:string -> replyText:string(max 300)
%%ax salesReply: requestText:string -> replyText:string(max 300)
%%ax send: replyText:string -> deliveredReply:string
classify{routeClass}
classify -->|support| supportReply
classify -->|sales| salesReply
supportReply --> send
salesReply --> send
```
Retry loop — a reviewer sends drafts back with a capped revise edge:
```text
flowchart TD
%%ax draft: briefText:string -> articleText:string(max 800)
%%ax review: articleText:string -> verdict:class "publish, revise", editorNote?:string
%%ax publish: articleText:string, editorNote?:string -> finalPost:string
draft --> review{verdict}
review -->|publish| publish
review -->|revise, max 2| draft
```
Fan-out / fan-in — two perspectives run in parallel, then a judge joins them:
```text
flowchart TD
%%ax outline: topicText:string -> questionText:string
%%ax proponent: questionText:string -> proArgument:string
%%ax skeptic: questionText:string -> conArgument:string
%%ax judge: proArgument:string, conArgument:string -> verdictSummary:string
outline --> proponent & skeptic
proponent & skeptic --> judge
```
While loop — repeat until a host-owned condition says stop (`flow(text, { conditions: { keepPolishing } })`):
```text
flowchart TD
%%ax polish: draftText:string -> polishedText:string
%%ax grade: polishedText:string -> qualityScore:number(min 0, max 1)
polish --> grade
grade -->|while keepPolishing, max 5| polish
```
Three-way branch and re-join — triage routes to one of three handlers before delivery:
```text
flowchart TD
%%ax triage: ticketText:string -> ticketClass:class "bug, billing, question"
%%ax bugHandler: ticketText:string -> replyText:string(max 300)
%%ax billingHandler: ticketText:string -> replyText:string(max 300)
%%ax questionHandler: ticketText:string -> replyText:string(max 300)
%%ax send: replyText:string -> deliveredReply:string
triage{ticketClass}
triage -->|bug| bugHandler
triage -->|billing| billingHandler
triage -->|question| questionHandler
bugHandler --> send
billingHandler --> send
questionHandler --> send
```
Judge panel — three independent drafts fan out, then converge on one verdict:
```text
flowchart TD
%%ax outline: topicText:string -> outlineText:string
%%ax draftA: outlineText:string -> draftAText:string
%%ax draftB: outlineText:string -> draftBText:string
%%ax draftC: outlineText:string -> draftCText:string
%%ax judge: draftAText:string, draftBText:string, draftCText:string -> verdictText:string
outline --> draftA & draftB & draftC
draftA & draftB & draftC --> judge
```
Escalation ladder — a quality gate either sends the first answer or falls back to level two:
```text
flowchart TD
%%ax l1Answer: ticketText:string -> answerText:string
%%ax qualityGate: answerText:string -> verdict:class "pass, escalate"
%%ax l2Answer: ticketText:string -> answerText:string
%%ax send: answerText:string -> deliveredAnswer:string
l1Answer --> qualityGate{verdict}
qualityGate -->|pass| send
qualityGate -->|escalate| l2Answer --> send
```
Itinerary planner — rich contracts stay attached to a simple linear graph:
```text
flowchart TD
%%ax parse: requestText:string -> destinationName:string, stayWindow:dateRange, travelerCount:number(min 1, max 12), budgetUsd?:number(min 0)
%%ax plan: destinationName:string, stayWindow:dateRange, travelerCount:number, budgetUsd?:number -> itineraryItems:object{ dayNumber:number(min 1), activityText:string }[]
%%ax price: itineraryItems:object{ dayNumber:number, activityText:string }[], travelerCount:number -> estimatedTotalUsd:number(min 0), bookingNotes?:string(max 300)
parse --> plan --> price
```
Fan-out with capped revision — two sections join, then review can send the assembly back twice:
```text
flowchart TD
%%ax outline: briefText:string -> outlineText:string
%%ax sectionA: outlineText:string -> sectionAText:string
%%ax sectionB: outlineText:string -> sectionBText:string
%%ax assemble: sectionAText:string, sectionBText:string -> articleText:string
%%ax review: articleText:string -> verdict:class "approve, revise", reviewNote?:string
%%ax publish: articleText:string, reviewNote?:string -> publishedArticle:string
outline --> sectionA & sectionB
sectionA & sectionB --> assemble --> review{verdict}
review -->|approve| publish
review -->|revise, max 2| assemble
```
## Examples
Fetch these for full working code:
- [Flow](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/ax-flow.ts) — complete flow usage
- [Mermaid Flow](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/ax-flow-mermaid.ts) — author/serialize a flow as a mermaid diagram
- [Auto-Parallel](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/ax-flow-auto-parallel.ts) — auto-parallelization
- [Async Map](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/ax-flow-async-map.ts) — async map transforms
- [Enhanced Demo](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/ax-flow-enhanced-demo.ts) — instance-based nodes
- [Flow as Function](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/ax-flow-to-function.ts) — flow as callable function
- [Fluent Builder](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/fluent-flow-example.ts) — fluent builder pattern
- [Adaptive Provider Balancing](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/typescript/generation/adaptive-balancer.ts) — cost, deadline, reliability, and failover routing
## Event-Triggered Flows
An AxFlow is an `AxProgrammable` event target. The runtime maps an event into
the Flow's typed initial state and propagates `eventContext`, cancellation, and
idempotency metadata to every node. Abandoned branches still use normal Flow
cancellation semantics.
Task-backed MCP tools called by a Flow node register a continuation on the
shared event context. `axMCPEventRoutes` observes progress and resumes the Flow
on input-required or terminal task notifications.
For resource-driven wake, discover the endpoint with `inspectCatalog()` and
give `AxMCPEventSource` an explicit `resourceSubscriptions` policy. Managed
subscriptions reconcile list changes and reconnect separately from the Flow;
subscription alone never starts or resumes a Flow.
UCP lifecycle webhooks use the same continuation boundary through
`AxUCPWebhookEventSource`. Correlate on `ucp.checkout` or `ucp.order` only after
the signed request has been verified and mapped to application identity.
Use `eventTarget('id').program(flow).wakeInput(...).resumeInput(...)` when wake
and resume events have different shapes. Segment-safe `eventPath` mappings are
validated against the Flow signature before any node executes; a declarative
`.waitFor(kind, path)` creates the owned continuation consumed by the resume
route.
Reusable `eventInput()` plans are the preferred callback-free boundary.
Callback `mapInput` is normalized against the Flow signature before any node
runs. In generated hosts, immediate publications dispatch inline; the host uses
`nextDueAt()` and `runDue()` for delayed retries, debounce, and continuation
expiry.
## Do Not Generate
- Do not use `new AxFlow(...)` for new code.
- Do not execute a node before defining it with `.node()`.
- Do not use removed terminal shapers like `.mapOutput()` or `.mo()`.
- Do not rely on broad signature inference from arbitrary transform source. Use explicit input/output generics and `.returns()` for the final output contract.
- Do not use generic field names like `text`, `result`, `data`, `input`, `output`.
- Do not create deep-nested state objects in `.map()`.
- Do not create loop conditions that can never change.
- Do not add unnecessary dependencies between executes (kills auto-parallelism).
- Do not forget to use optional chaining on branch results after `.merge()`.

View File

@@ -1,543 +0,0 @@
---
name: ax-gen
description: This skill helps an LLM generate correct AxGen code using @ax-llm/ax. Use when the user asks about ax(), AxGen, generators, forward(), streamingForward(), validation, assertions, streaming assertions, field processors, step hooks, self-tuning, or structured outputs. For MCP clients, transports, prompts, resources, tasks, subscriptions, or authentication use ax-mcp alongside this skill.
version: "23.0.9"
---
# AxGen Codegen Rules (@ax-llm/ax)
Use this skill to generate `AxGen` code. Prefer short, modern, copyable patterns. Do not write tutorial prose unless the user explicitly asks for explanation.
Use the `ax-mcp` skill when AxGen attaches native MCP clients or consumes MCP
prompts, resources, tools, tasks, subscriptions, authentication, or events.
## Use These Defaults
- Use `ax(...)` factory, not `new AxGen(...)`.
- Always pass an AI instance from `ai(...)` as the first argument to `forward()`.
- Streaming uses `streamingForward()`, not `forward()` with a stream option.
- Use schema validation for field shape and constraints.
- Use `addAssert(...)` for whole-output hard invariants with correction retries.
- Use `addStreamingAssert(...)` for partial streaming hard invariants with fail-fast per-attempt correction retries.
- Use `bestOfN(...)` / `refine(...)` for reward-scored complete outputs.
- Step hook mutations are applied at the next step boundary (pending pattern).
- `stopFunction` accepts a string or string[] for multiple stop functions.
- Multi-step continues until: all outputs filled, stop function called, or `maxSteps` reached.
## Canonical Pattern
```typescript
import { ai, ax, s } from '@ax-llm/ax';
const llm = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
});
// Inline signature
const gen = ax('input:string -> output:string, reasoning:string');
// Reusable signature
const sig = s('question:string, context:string[] -> answer:string');
const gen2 = ax(sig);
// With options
const gen3 = ax('input -> output', {
description: 'A helpful assistant',
maxRetries: 3,
maxSteps: 10,
temperature: 0.7,
});
const result = await gen.forward(llm, { input: 'Hello world' });
console.log(result.output);
```
### Signatures from zod / valibot / arktype
`ax()` accepts any signature built with `f()`, and `f().input()` / `.output()` accept [Standard Schema v1](https://standardschema.dev) validators directly — per-field or a whole `z.object({...})`:
```typescript
import { z } from 'zod';
import { ax, f } from '@ax-llm/ax';
const gen = ax(
f()
.input(z.object({
productName: z.string(),
buyerProfile: z.string(),
}))
.output(z.object({
headline: z.string(),
recommendation: z.enum(['buy', 'wait', 'skip']),
}))
.build()
);
```
Constraints (`.min()`, `.email()`, `.regex()`) and custom logic (`.refine()`, `.transform()`, `.superRefine()`) execute in the normal validation/retry pipeline — at parse time on complete field values, including at field boundaries during streaming. For cache/internal hints pass companion options: `.input('ctx', z.string(), { cache: true })` or `.output('reasoning', z.string(), { internal: true })`.
Define tool functions with zod the same way — `fn().arg()` / `.returns()` accept per-argument or whole-object schemas and infer the handler's argument type:
```typescript
import { z } from 'zod';
import { ax, fn } from '@ax-llm/ax';
const lookupProduct = fn('lookupProduct')
.description('Look up a product by name')
.arg(z.object({
productName: z.string().min(1),
includeSpecs: z.boolean().optional(),
}))
.returns(z.object({
price: z.number(),
inStock: z.boolean(),
rating: z.number().min(1).max(5),
}))
.handler(async ({ productName, includeSpecs }) => ({
price: 79.99,
inStock: true,
rating: 4.3,
}))
.build();
const result = await gen.forward(llm, { ... }, { functions: [lookupProduct] });
```
## Running AxGen
### `forward()`
```typescript
const result = await gen.forward(llm, { input: '...' });
// With options
const result = await gen.forward(llm, { input: '...' }, {
maxRetries: 5,
model: 'gpt-5.4-mini',
modelConfig: { temperature: 0.9, maxTokens: 1000 },
debug: true,
});
```
### Live Global Defaults
`AxGen` respects `axGlobals` for app-wide runtime defaults:
```typescript
import { axGlobals } from '@ax-llm/ax';
import { trace } from '@opentelemetry/api';
const responseCache = new Map<string, any>();
axGlobals.tracer = trace.getTracer('my-app');
axGlobals.debug = true;
axGlobals.cachingFunction = async (key, value?) => {
if (value !== undefined) {
responseCache.set(key, value);
return;
}
return responseCache.get(key);
};
```
Rules:
- Tracing/logging precedence is: forward options, then generator options, then AI service options, then current `axGlobals`, then built-in defaults.
- `abortSignal` from `axGlobals` is merged with local forward signals.
- `customLabels` merge from globals to AI service to forward options.
- `cachingFunction` and `functionResultFormatter` also fall back to current `axGlobals` when local options do not provide them.
### `streamingForward()`
```typescript
const stream = gen.streamingForward(llm, { input: 'Write a long story' });
for await (const chunk of stream) {
if (chunk.delta.output) process.stdout.write(chunk.delta.output);
}
```
## Stopping And Cancellation
```typescript
import { AxAIServiceAbortedError } from '@ax-llm/ax';
const timer = setTimeout(() => gen.stop(), 3_000);
try {
const result = await gen.forward(llm, { topic: 'Long document' }, {
abortSignal: AbortSignal.timeout(10_000),
});
} catch (err) {
if (err instanceof AxAIServiceAbortedError) console.log('Aborted');
}
```
Rules:
- `gen.stop()` gracefully stops multi-step execution at the next step boundary.
- `abortSignal` cancels the underlying AI service call immediately.
- Catch `AxAIServiceAbortedError` when using either mechanism.
## Validation, Selection, And Guards
```typescript
import { ax, bestOfN, f } from '@ax-llm/ax';
import { z } from 'zod';
// Schema validation: output shape and field validity.
const gen = ax(
f()
.input('topic', z.string().min(1))
.output('summary', z.string().min(50))
.build()
);
// bestOfN: choose the best complete candidate.
const selected = bestOfN(gen, {
n: 4,
rewardFn: ({ prediction }) => prediction.summary.length,
});
// Whole-output assertion: retries with correction feedback.
gen.addAssert(
(output) => output.summary.includes(topic) || 'Summary must mention the topic.'
);
// Streaming assertion: fail fast on unsafe partial output.
gen.addStreamingAssert(
'summary',
(text) => !text.includes('forbidden'),
'Output contains forbidden text'
);
```
Rules:
- Schema validation retries with parser/constraint feedback.
- `addAssert(...)` checks the complete parsed output after validation/processors and retries with correction feedback on failure.
- `bestOfN(...)` scores complete candidates and returns the highest reward or first threshold hit.
- `refine(...)` runs rounds and can feed reward-derived advice into instruction components between rounds.
- `addStreamingAssert(...)` targets a string/code output field and receives partial text so far.
- Streaming assertions abort the current stream attempt by throwing `AxStreamingAssertionError`, then feed correction feedback into AxGen retries.
## Field Processors
```typescript
// Post-processing after generation
gen.addFieldProcessor('summary', (value, context) => value.toUpperCase());
// Streaming field processor (called on each chunk)
gen.addStreamingFieldProcessor('content', (partialValue, context) => {
console.log(`Received ${partialValue.length} chars`);
return partialValue;
});
```
Rules:
- `addFieldProcessor` runs once after the field is fully generated.
- `addStreamingFieldProcessor` runs on each streaming chunk for the target field.
- Both must return the (possibly transformed) value.
## Function Calling
```typescript
const result = await gen.forward(llm, { question: '...' }, {
functions: tools,
functionCallMode: 'auto',
stopFunction: 'finalAnswer',
});
```
Rules:
- `functionCallMode` can be `'auto'`, `'none'`, or a specific function name to force.
- `stopFunction` accepts a string or string[] to halt multi-step on specific function calls.
- Multi-step continues until all outputs filled, stop function called, or `maxSteps` reached.
## Caching
### Response Caching
```typescript
const gen = ax('question:string -> answer:string', {
cachingFunction: async (key, value?) => {
if (value !== undefined) {
await cache.set(key, value);
return;
}
return await cache.get(key);
},
});
```
### Context Caching
```typescript
const result = await gen.forward(llm, { question: '...' }, {
contextCache: { cacheBreakpoint: 'after-examples' },
});
```
Rules:
- `cachingFunction` acts as a get/set: called with `(key)` to read, `(key, value)` to write.
- `contextCache` enables AI provider-level prompt caching for long context.
## Sampling And Result Picker
```typescript
const result = await gen.forward(llm, { question: '...' }, {
sampleCount: 3,
resultPicker: async (samples) => {
// Evaluate each sample and return the index of the best one
return bestIndex;
},
});
```
Rules:
- `sampleCount` generates multiple completions in parallel.
- `resultPicker` receives all samples and must return the index of the chosen result.
## Extended Thinking
```typescript
const result = await gen.forward(llm, { question: '...' }, {
thinkingTokenBudget: 'medium',
showThoughts: true,
});
console.log(result.thought);
```
Rules:
- `thinkingTokenBudget` can be `'low'`, `'medium'`, `'high'`, or a number.
- Set `showThoughts: true` to include the model's reasoning in `result.thought`.
## Structured Outputs
```typescript
const sig = f()
.input('text', f.string())
.output('summary', f.string())
.output('metadata', f.json().optional())
.useStructured()
.build();
```
Rules:
- `.useStructured()` asks providers with native support, including OpenAI, Anthropic, and Gemini, for schema-constrained JSON.
- Native structured-output schemas list every object property in `required`, set `additionalProperties: false` on objects, and express optional fields as nullable types.
- Flexible `json` fields and unshaped `object` fields are sent as JSON-encoded strings for native structured outputs, then parsed back into normal JavaScript values.
## Step Hooks
```typescript
const result = await gen.forward(llm, values, {
stepHooks: {
beforeStep: (ctx) => {
if (ctx.functionsExecuted.has('complexanalysis')) {
ctx.setModel('smart');
ctx.setThinkingBudget('high');
}
},
afterStep: (ctx) => {
console.log(`Usage: ${ctx.usage.totalTokens} tokens`);
},
},
});
```
### AxStepContext Read-Only Properties
- `stepIndex` - current step number
- `maxSteps` - configured maximum steps
- `isFirstStep` - whether this is the first step
- `functionsExecuted` - `Set<string>` of function names called so far
- `lastFunctionCalls` - array of the most recent function call results
- `usage` - token usage statistics
- `state` - current step state
### AxStepContext Mutators
- `setModel(model)` - change the model for the next step
- `setThinkingBudget(budget)` - adjust thinking budget
- `setTemperature(temp)` - adjust temperature
- `setMaxTokens(max)` - adjust max output tokens
- `setOptions(opts)` - set arbitrary forward options
- `addFunctions(fns)` - add functions for the next step
- `removeFunctions(names)` - remove functions by name
- `stop()` - stop multi-step execution
Rules:
- All mutations are pending and applied at the next step boundary.
- `beforeStep` runs before each LLM call; `afterStep` runs after.
- Use `afterFunctionExecution` to react to specific function results.
## Self-Tuning
```typescript
// Simple: enable all self-tuning
const result = await gen.forward(llm, values, { selfTuning: true });
// Granular: pick what to tune
const result = await gen.forward(llm, values, {
selfTuning: {
model: true,
thinkingBudget: true,
functions: [searchWeb, calculate],
},
});
```
Rules:
- `selfTuning: true` enables automatic model and parameter selection.
- Granular config allows tuning specific aspects independently.
- `selfTuning.functions` provides a pool of functions the tuner may add or remove per step.
## Error Handling
```typescript
import { AxGenerateError } from '@ax-llm/ax';
try {
const result = await gen.forward(llm, { input: '...' });
} catch (error) {
if (error instanceof AxGenerateError) {
console.log(error.details.model, error.details.signature);
}
}
```
Rules:
- `AxGenerateError` includes `details` with `model` and `signature` for debugging.
- `AxAIServiceAbortedError` is thrown on cancellation via `stop()` or `abortSignal`.
## Chat Log and Usage
### getChatLog()
After any `.forward()` or `streamingForward()` call, `gen.getChatLog()` returns the full normalized chat history — every `ai.chat()` round-trip, including the system prompt, all messages, and the model response. The log is reset at the start of each `.forward()` call. Multi-step generators (with function calls) produce one entry per step.
```typescript
await gen.forward(llm, { question: 'What is 2+2?' });
for (const entry of gen.getChatLog()) {
console.log('model:', entry.model);
for (const msg of entry.messages) {
console.log(`[${msg.role}]`, msg.content);
}
console.log('tokens:', entry.modelUsage?.tokens);
}
```
Message roles: `system`, `user`, `assistant`, `tool`. Assistant content uses inline XML:
- `<think>...</think>` — reasoning/thinking tokens
- `<tool_call>\n{...}\n</tool_call>` — tool invocations
The system message includes a `<tools>` JSON block when functions are present.
```typescript
type AxChatLogMessage =
| { role: 'system'; content: string }
| { role: 'user'; content: string }
| { role: 'assistant'; content: string }
| { role: 'tool'; name: string; content: string };
type AxChatLogEntry = {
name?: string;
model: string;
messages: AxChatLogMessage[];
modelUsage?: AxProgramUsage;
};
gen.getChatLog(): readonly AxChatLogEntry[]
```
### getUsage()
Returns token usage aggregated by `(ai, model)` across all steps. When a provider reports prompt-cache usage, `promptTokens` is the uncached input portion and `cacheReadTokens` / `cacheCreationTokens` carry the cache counters. Reset with `resetUsage()`.
```typescript
const usage = gen.getUsage(); // AxProgramUsage[]
console.log(usage[0]?.tokens?.promptTokens);
gen.resetUsage();
```
`AxAgent` and `AxFlow` also return flat `AxChatLogEntry[]` logs; composite programs set `entry.name` so callers can filter by node/stage.
## Examples
Fetch these for full working code:
- [Streaming](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/streaming.ts) — field-by-field streaming
- [Best Of N](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/best-of-n.ts) — reward-scored sample selection
- [Refine](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/refine.ts) — retry rounds with generated feedback
- [Streaming Assert](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/streaming-asserts.ts) — fail-fast partial-output correction
- [Structured Output](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/structured_output.ts) — fluent API with validation
- [Debug Logging](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/debug-logging.ts) — debug mode and step hooks
- [Stop Function](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/stop-function.ts) — stop functions
- [Fibonacci](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/fibonacci.ts) — streaming with thinking
- [Extraction](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/extract.ts) — information extraction
- [Multi-Sampling](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/sample-count.ts) — sample count usage
## Native MCP/UCP
Use `ax-mcp` for client construction, transports, authentication, catalog and
task APIs, subscriptions, event routing, and recording/replay. This section
only covers the AxGen attachment boundary.
Pass live clients directly to constructor or forward options:
```typescript
const gen = ax('question:string -> answer:string', { mcp: [docs, search] });
const result = await gen.forward(llm, { question }, {
mcpContext: [
{ client: 'docs', resource: { uri: 'docs://guide' } },
],
});
```
The model receives native tool definitions. Structured, image, audio, resource-link, embedded-resource, metadata, task, and error results are preserved until the provider adapter maps supported content. Streaming keeps MCP progress/task events separate from Ax output. Never call `toFunction()` for native integration.
Use `client.inspectCatalog()` when an endpoint is the only configuration. It
discovers server-owned tool/prompt names, concrete resource URIs, and URI
templates. Event sources require an explicit none/all/URI/selector resource
subscription policy and never create a wake route implicitly.
Under an event target, a required task-backed MCP tool registers the owning
`namespace:taskId` continuation automatically. Use `AxMCPEventSource` plus
`axMCPEventRoutes` to observe progress and resume the target on
`input_required` or a terminal state.
## Event Targets
Wrap an AxGen with
`eventTarget('id').program(gen).ai(ai).input(...).build()` to invoke it from an
explicit `wake` or `resume` route. Use segment-safe `eventPath` selectors;
projection and explicit fields are validated against the AxGen signature before
invocation. Use `.wakeInput()` and `.resumeInput()` for different action
contracts. Streaming targets persist each chunk before optional chunk sinks and
persist the final result before final sinks.
Use a reusable `eventInput().project(...).field(...)` plan when mapping should
be callback-free. Callback `mapInput` remains available, but its result is
cloned, stripped to declared AxGen inputs, and signature-validated before the
first model call; mapper exceptions become non-retryable
`event_input_invalid` deliveries.
## Do Not Generate
- Do not use `new AxGen(...)` for new code unless explicitly required.
- Do not pass raw API keys or config objects where an `ai(...)` instance is expected.
- Do not use `forward()` for streaming; use `streamingForward()`.
- Do not use streaming assertions as reward/refine mechanisms; they enforce hard partial-output invariants and retry with correction.
- Do not mutate step hook context expecting immediate effect; mutations are pending until the next step.
- Do not assume multi-step stops after one LLM call; it continues until outputs are filled, a stop function fires, or `maxSteps` is reached.

View File

@@ -1,264 +0,0 @@
---
name: ax-gepa
description: This skill helps an LLM generate correct AxGEPA optimization code using @ax-llm/ax. Use when the user asks about AxGEPA, GEPA, Pareto optimization, multi-objective prompt tuning, reflective prompt evolution, validationExamples, maxMetricCalls, or optimizing a generator, flow, or agent tree.
version: "23.0.9"
---
# GEPA Optimization Codegen Rules (@ax-llm/ax)
Use this skill to generate GEPA optimization code. Prefer the top-level `optimize(...)` helper for normal code, and use direct `AxGEPA` / `AxBootstrapFewShot` only when the user needs low-level optimizer control.
## Use These Defaults
- Use `optimize(program, train, metric, { studentAI, teacherAI, ... })` for normal generator and flow tuning.
- Prefer `ai()`, `ax()`, and `flow()` for new code.
- Use a strong `teacherAI` and a cheaper `studentAI`.
- Pass `validationExamples` when you have a holdout set.
- Set `maxMetricCalls` to bound optimizer cost; `optimize(...)` defaults it to `100`.
- Use scalar metrics for one objective and object metrics for Pareto optimization.
- Apply results with `program.applyOptimization(result.optimizedProgram!)`.
- For tree-wide runs, expect `optimizedProgram.componentMap`.
- Persist artifacts with `axSerializeOptimizedProgram(...)` and restore them with `axDeserializeOptimizedProgram(...)` so the same flow works in browsers and Node.
- `optimize(...)` runs `AxBootstrapFewShot -> AxGEPA` for small starter sets by default, preserving the demos in `result.optimizedProgram.demos`.
## Critical Rules
- `optimize(...)` and `AxGEPA.compile()` work for a single generator and for tree-aware roots such as flows or agents with registered optimizable descendants.
- There is no separate flow-only GEPA optimizer. Use `AxGEPA` for flows too.
- The metric may return either `number` or `Record<string, number>`.
- Keep metrics deterministic and cheap by default.
- Avoid extra LLM calls inside the metric unless the user explicitly wants judge-based evaluation.
- If the user needs LLM-as-judge scoring for a non-agent GEPA run, prefer a plain typed `AxGen` evaluator instead of writing a custom judge abstraction.
- `maxMetricCalls` must be large enough to cover the initial validation pass over `validationExamples`.
- GEPA optimizes generic string components exposed by `getOptimizableComponents()`. If a tree exposes no components, optimization will fail.
- Use held-out validation examples for selection. Do not reuse the training set as `validationExamples`.
- `result.optimizedProgram` is the easy-to-apply best candidate. `result.paretoFront` is the full trade-off set for multi-objective runs.
- Direct `AxGEPA` still has its own `bootstrap` option, but top-level `optimize(...)` composes the existing `AxBootstrapFewShot` optimizer before GEPA instead.
## Metric Selection
Choose the evaluation path deliberately:
- Prefer a deterministic metric when correctness can be read directly from `prediction` and `example`.
- Prefer a deterministic metric when cost, latency, recursion depth, or tool count matters.
- Use a plain typed `AxGen` evaluator only when the task is genuinely qualitative and hard to score exactly.
- For `agent.optimize(...)`, prefer the built-in judge path instead of manually wrapping a judge metric. Normal agent users usually do not need to set `target` or `metric` at all.
Rule of thumb:
- `optimize(...)` on `AxGen` or flow: use a metric first, optionally a plain typed `AxGen` evaluator if needed.
- `agent.optimize(...)`: use custom `metric` for crisp scoring, otherwise let the built-in judge handle scoring. Add `judgeAI` plus `judgeOptions` only when you want a stronger or separate judge model.
## Canonical Scalar Pattern
```typescript
import { ai, ax, optimize, AxAIOpenAIModel } from '@ax-llm/ax';
const student = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
config: { model: AxAIOpenAIModel.GPT54Mini },
});
const teacher = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
config: { model: AxAIOpenAIModel.GPT54 },
});
const classifier = ax(
'emailText:string -> priority:class "high, normal, low", rationale:string'
);
const train = [
{ emailText: 'URGENT: Server down!', priority: 'high' },
{ emailText: 'Weekly newsletter', priority: 'low' },
];
const validation = [
{ emailText: 'Invoice overdue', priority: 'high' },
{ emailText: 'Lunch plans?', priority: 'low' },
];
const metric = ({ prediction, example }: { prediction: any; example: any }) =>
prediction?.priority === example?.priority ? 1 : 0;
const result = await optimize(classifier, train, metric, {
studentAI: student,
teacherAI: teacher,
numTrials: 12,
minibatch: true,
minibatchSize: 4,
earlyStoppingTrials: 4,
sampleCount: 1,
validationExamples: validation,
maxMetricCalls: 120,
});
classifier.applyOptimization(result.optimizedProgram!);
console.log(result.bestScore);
```
## Canonical Pareto Pattern
```typescript
import { ai, flow, optimize, AxAIOpenAIModel } from '@ax-llm/ax';
const student = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
config: { model: AxAIOpenAIModel.GPT54Mini },
});
const teacher = ai({
name: 'openai',
apiKey: process.env.OPENAI_APIKEY!,
config: { model: AxAIOpenAIModel.GPT54 },
});
const wf = flow<{ emailText: string }>()
.n('classifier', 'emailText:string -> priority:class "high, normal, low"')
.n(
'rationale',
'emailText:string, priority:string -> rationale:string "One concise sentence"'
)
.e('classifier', (state) => ({ emailText: state.emailText }))
.e('rationale', (state) => ({
emailText: state.emailText,
priority: state.classifierResult.priority,
}))
.r((state) => ({
priority: state.classifierResult.priority,
rationale: state.rationaleResult.rationale,
}));
const train = [
{ emailText: 'URGENT: Server down!', priority: 'high' },
{ emailText: 'Weekly newsletter', priority: 'low' },
];
const validation = [
{ emailText: 'Invoice overdue', priority: 'high' },
{ emailText: 'Lunch plans?', priority: 'low' },
];
const metric = ({ prediction, example }: { prediction: any; example: any }) => {
const accuracy = prediction?.priority === example?.priority ? 1 : 0;
const rationale = typeof prediction?.rationale === 'string'
? prediction.rationale
: '';
const brevity = rationale.length <= 40 ? 1 : rationale.length <= 80 ? 0.5 : 0.1;
return { accuracy, brevity };
};
const result = await optimize(wf, train, metric, {
studentAI: student,
teacherAI: teacher,
numTrials: 16,
minibatch: true,
minibatchSize: 6,
earlyStoppingTrials: 5,
sampleCount: 1,
validationExamples: validation,
maxMetricCalls: 240,
});
for (const point of result.paretoFront) {
console.log(point.scores, point.configuration);
}
wf.applyOptimization(result.optimizedProgram!);
console.log(result.optimizedProgram?.componentMap);
```
## Metric Patterns
```typescript
// Scalar objective
const scalarMetric = ({ prediction, example }) =>
prediction.answer === example.answer ? 1 : 0;
// Multi-objective
const multiMetric = ({ prediction, example }) => ({
accuracy: prediction.answer === example.answer ? 1 : 0,
brevity:
typeof prediction?.reasoning === 'string' &&
prediction.reasoning.length < 120
? 1
: 0.2,
});
```
- Return plain numbers or plain object literals.
- Keep objective names stable across calls.
- Prefer normalized scores such as `0..1` so trade-offs are easy to reason about.
## Result Handling
```typescript
const { optimizedProgram, paretoFront } = result;
program.applyOptimization(optimizedProgram!);
// Save for later
const saved = JSON.stringify(optimizedProgram);
// Load later and re-apply
const loaded = JSON.parse(saved);
program.applyOptimization(loaded);
```
- Single-target runs usually populate both `optimizedProgram.instruction` and `optimizedProgram.componentMap`.
- Tree-wide runs rely on `componentMap`, keyed by full component key.
- Pareto points expose candidate configs under `point.configuration.componentMap`.
## Useful Options
```typescript
const optimizer = new AxGEPA({
studentAI,
teacherAI,
numTrials: 20,
minibatch: true,
minibatchSize: 5,
minibatchFullEvalSteps: 5,
earlyStoppingTrials: 5,
minImprovementThreshold: 0,
sampleCount: 1,
seed: 42,
verbose: true,
});
```
- `numTrials`: number of reflection/evolution rounds.
- `minibatch`: reduce per-round evaluation cost.
- `minibatchSize`: examples per minibatch.
- `earlyStoppingTrials`: stop after repeated non-improvement.
- `minImprovementThreshold`: reject tiny gains below this threshold.
- `seed`: stabilize sampling during demos and tests.
## Budgeting and Validation
- Always create distinct `train` and `validationExamples` arrays.
- Size `maxMetricCalls` for at least one full validation pass plus several rounds.
- If the user wants a strict budget, say so explicitly and set `maxMetricCalls`.
- For expensive trees, start with `auto: 'light'` or fewer `numTrials`, then scale up.
- GEPA selects among exposed components using measured accept/reject history, not LLM-generated numeric scores. The LLM proposes component text; metrics decide whether to keep it.
- Function/tool trace reflection is keyed by stable component IDs where available, so function renames do not break saved candidate maps.
## Troubleshooting
- Error about `maxMetricCalls` being too small: increase it until the initial validation pass fits.
- Empty or poor Pareto front: verify the metric returns numbers for every example.
- No tree optimization effect: ensure child programs are registered under the root and expose optimizable components.
- Saved optimization applies only partly: use `program.applyOptimization(...)`, not just `setInstruction(...)`, so `componentMap` reaches the full tree.
- Agent target seems too broad: when using `agent.optimize(...)`, set `target: 'actor'`, `'responder'`, `'all'`, or explicit program IDs. The wrapper filters GEPA components to the selected target.
## Good Example Targets
- `/Users/vr/src/ax/src/examples/optimize.ts`
- `/Users/vr/src/ax/src/examples/gepa.ts`
- `/Users/vr/src/ax/src/examples/gepa-flow.ts`
- `/Users/vr/src/ax/src/examples/gepa-train-inference.ts`
- `/Users/vr/src/ax/src/examples/gepa-quality-vs-speed-optimization.ts`
- `/Users/vr/src/ax/src/examples/axagent-gepa-optimization.ts`

View File

@@ -1,351 +0,0 @@
---
name: ax-llm
description: This skill helps with using the @ax-llm/ax TypeScript library for building LLM applications. Use when the user asks about ax(), ai(), f(), s(), agent(), flow(), AxGen, AxAgent, AxFlow, signatures, streaming, or mentions @ax-llm/ax.
version: "23.0.9"
---
# Ax Library (@ax-llm/ax) Quick Reference
Ax is a TypeScript library for building LLM-powered applications with type-safe signatures, streaming support, and multi-provider compatibility.
> **Detailed skills available:** ax-ai (providers, routing, adaptive balancing), ax-signature (signatures/types), ax-gen (generators), ax-agent (core agents/tools), ax-agent-rlm (agent runtime/RLM/delegation), ax-agent-observability (callbacks/logs/usage), ax-agent-memory-skills (recall and dynamic skill loading), ax-agent-optimize (agent tuning/eval), ax-flow (workflows), ax-gepa (top-level `optimize(...)`, BootstrapFewShot -> GEPA, Pareto optimization).
## Imports & Factories
```typescript
// Prefer factory functions: ax(), ai(), agent(), flow(); avoid class constructors.
import { ax, ai, f, s, fn, agent, flow, AxMemory, AxMCPClient } from '@ax-llm/ax';
import { z } from 'zod'; // optional — any Standard Schema v1 library works
// AI provider
const llm = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY });
// Generator (from string signature)
const gen = ax('question:string -> answer:string');
// Generator (from fluent signature)
const gen = ax(
f()
.input('question', f.string('User question'))
.output('answer', f.string('AI response'))
.build()
);
// Generator (from zod — Standard Schema v1, also works with valibot/arktype)
const zodGen = ax(
f()
.input(z.object({ question: z.string().describe('User question') }))
.output(z.object({ answer: z.string().describe('AI response') }))
.build()
);
// Reusable signature
const sig = s('question:string, context:string[] -> answer:string');
// Agent
const myAgent = agent('userInput:string -> response:string', {
name: 'helper',
description: 'A helpful assistant',
});
// Flow
const wf = flow<{ input: string }, { output: string }>()
.node('step1', 'input:string -> output:string')
.execute('step1', (state) => ({ input: state.input }))
.returns((state) => ({ output: state.step1Result.output }));
// Function tool — native fluent
const tool = fn('search')
.description('Search the web')
.arg('query', f.string('Search query'))
.returns(f.string('Search results'))
.handler(({ query }) => searchWeb(query))
.build();
// Function tool — zod schema (Standard Schema v1: also works with valibot, arktype)
const zodTool = fn('calculateTax')
.description('Calculate tax for an amount')
.arg(z.object({
amount: z.number().positive().describe('Pre-tax amount in USD'),
region: z.enum(['US', 'EU', 'UK']).describe('Tax region'),
}))
.returns(z.object({ tax: z.number(), total: z.number() }))
.handler(async ({ amount }) => ({ tax: amount * 0.1, total: amount * 1.1 }))
.build();
```
## Running
```typescript
// Forward (blocking)
const result = await gen.forward(llm, { question: 'What is 2+2?' });
// Streaming
for await (const chunk of gen.streamingForward(llm, { question: 'Tell a story' })) {
if (chunk.delta.answer) process.stdout.write(chunk.delta.answer);
}
```
## Forward Options Quick Reference
| Goal | Option | Example |
|------|--------|---------|
| Model override | `model` | `{ model: 'gpt-5.4-mini' }` |
| Temperature | `modelConfig.temperature` | `{ modelConfig: { temperature: 0.8 } }` |
| Max tokens | `modelConfig.maxTokens` | `{ modelConfig: { maxTokens: 500 } }` |
| Retry on failure | `maxRetries` | `{ maxRetries: 3 }` |
| Max agent steps | `maxSteps` | `{ maxSteps: 10 }` |
| Fail fast | `fastFail` | `{ fastFail: true }` |
| Thinking budget | `thinkingTokenBudget` | `{ thinkingTokenBudget: 'medium' }` |
| Show thoughts | `showThoughts` | `{ showThoughts: true }` |
| Context caching | `contextCache` | `{ contextCache: { cacheBreakpoint: 'after-examples' } }` |
| Multi-sampling | `sampleCount` | `{ sampleCount: 5 }` |
| Debug logging | `debug` | `{ debug: true }` |
| Abort signal | `abortSignal` | `{ abortSignal: controller.signal }` |
| Memory | `mem` | `{ mem: new AxMemory() }` |
| Stop function | `stopFunction` | `{ stopFunction: 'finalAnswer' }` |
| Function mode | `functionCallMode` | `{ functionCallMode: 'auto' }` |
Global runtime defaults can be set with `axGlobals` and are read live by future AI, AxGen, and AxFlow calls:
```typescript
import { axGlobals, axCreateDefaultColorLogger } from '@ax-llm/ax';
import { trace } from '@opentelemetry/api';
axGlobals.tracer = trace.getTracer('my-app');
axGlobals.debug = true;
axGlobals.logger = axCreateDefaultColorLogger();
```
Precedence is: per-call options, then explicit instance/program options, then current `axGlobals`, then built-in defaults. `customLabels` merge in that order, and `abortSignal` values are combined so either global or local cancellation works.
## Memory and Context
```typescript
import { AxMemory } from '@ax-llm/ax';
const memory = new AxMemory();
// Multi-turn conversation
await gen.forward(llm, { userMessage: 'My name is Alice' }, { mem: memory });
const r = await gen.forward(llm, { userMessage: 'What is my name?' }, { mem: memory });
```
## Few-Shot Examples
```typescript
const classifier = ax('reviewText:string -> sentiment:class "positive, negative, neutral"');
classifier.setExamples([
{ reviewText: 'I love this!', sentiment: 'positive' },
{ reviewText: 'Terrible.', sentiment: 'negative' },
{ reviewText: 'It works.', sentiment: 'neutral' },
]);
```
## Common Patterns
### Classification
```typescript
const classifier = ax(
f()
.input('text', f.string())
.output('category', f.class(['spam', 'ham', 'uncertain']))
.output('confidence', f.number().min(0).max(1))
.build()
);
```
### Extraction
```typescript
const extractor = ax(
f()
.input('text', f.string())
.output('entities', f.object({
people: f.string().array(),
organizations: f.string().array(),
locations: f.string().array()
}))
.build()
);
```
### Multi-modal (Images)
```typescript
const analyzer = ax(
f()
.input('image', f.image('Image to analyze'))
.input('question', f.string('Question').optional())
.output('description', f.string())
.output('objects', f.string().array())
.build()
);
const result = await analyzer.forward(llm, {
image: { mimeType: 'image/jpeg', data: base64Data },
question: 'What objects are in this image?'
});
```
### Chaining Generators
```typescript
const researcher = ax('topic:string -> research:string, keyFacts:string[]');
const writer = ax('research:string, keyFacts:string[] -> article:string');
const research = await researcher.forward(llm, { topic: 'AGI' });
const draft = await writer.forward(llm, { research: research.research, keyFacts: research.keyFacts });
```
## Error Handling
```typescript
import { AxGenerateError, AxAIServiceError, AxAIServiceAbortedError } from '@ax-llm/ax';
try {
const result = await gen.forward(llm, { input: 'test' });
} catch (error) {
if (error instanceof AxGenerateError) {
console.error('Generation failed:', error.details.model, error.details.signature);
} else if (error instanceof AxAIServiceAbortedError) {
console.log('Request was aborted');
} else if (error instanceof AxAIServiceError) {
console.error('AI service error:', error.message);
}
}
```
## Debugging
```typescript
import { axCreateDefaultColorLogger, axGlobals } from '@ax-llm/ax';
const result = await gen.forward(llm, { input: 'test' }, {
debug: true,
logger: axCreateDefaultColorLogger(),
// OpenTelemetry
tracer: openTelemetryTracer,
meter: openTelemetryMeter,
});
// Or set live app-wide defaults for future calls:
axGlobals.tracer = openTelemetryTracer;
axGlobals.meter = openTelemetryMeter;
```
## MCP Integration
Use the `ax-mcp` skill for the complete native client, transport,
authentication, catalog, task, subscription, event, and replay workflow.
```typescript
import { AxMCPClient, agent } from '@ax-llm/ax';
import { AxMCPStdioTransport } from '@ax-llm/ax-tools';
// Stdio transport (local MCP server)
const transport = new AxMCPStdioTransport({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-memory'],
});
const mcpClient = new AxMCPClient(transport, { namespace: 'memory' });
// Native MCP context is initialized once and inherited by all agent stages.
const myAgent = agent('userMessage:string -> response:string', {
mcp: mcpClient,
functionDiscovery: true,
contextFields: [],
});
const result = await myAgent.forward(llm, { userMessage: 'Remember this.' });
await mcpClient.close(); // caller-owned clients remain caller-owned
```
### HTTP Transport (Remote MCP)
```typescript
import { AxMCPStreamableHTTPTransport } from '@ax-llm/ax';
const transport = new AxMCPStreamableHTTPTransport('https://remote.example/mcp', {
headers: { 'x-pd-project-id': projectId },
authorization: `Bearer ${accessToken}`,
});
```
### Native MCP and UCP behavior
- Pass `mcp` and `ucp` to AxGen, streaming AxGen, chat, AxAgent, AxFlow, optimization, or evaluation options.
- Use `mcpContext` to inject attributed prompts/resources before the first model call.
- Use `mcpInheritance: 'all' | 'none' | string[]` to restrict child programs.
- Tool calls retain raw MCP content, metadata, errors, tasks, and protocol provenance in memory.
- AxAgent exposes native modules as `mcp.<namespace>` and `ucp.<namespace>`.
- `inspectCatalog()` discovers tool/prompt names, concrete resources, and URI
templates from only an endpoint. Resource event sources default to no
subscriptions and require an explicit all/URI/selector policy.
- `toFunction()` remains a compatibility adapter only; native Ax execution never uses it.
- Live optimization is rejected by default. Use recording/replay or explicitly opt into live MCP evaluation.
```typescript
const catalog = await mcpClient.inspectCatalog();
const tools = catalog.tools;
const prompts = await mcpClient.listPrompts();
const resource = await mcpClient.readResource('docs://guide');
const tasks = await mcpClient.listTasks();
```
### Function Overrides
```typescript
const mcpClient = new AxMCPClient(transport, {
functionOverrides: [
{ name: 'search_documents', updates: { name: 'findDocs', description: 'Search docs' } }
]
});
```
## Type Reference
```typescript
class AxGen<IN, OUT> {
forward(ai: AxAIService, values: IN, options?: AxProgramForwardOptions): Promise<OUT>;
streamingForward(ai: AxAIService, values: IN, options?: AxProgramStreamingForwardOptions): AsyncGenerator<{ delta: Partial<OUT> }>;
setExamples(examples: Array<Partial<IN & OUT>>): void;
addAssert(fn: (output: OUT) => boolean | string | undefined | Promise<boolean | string | undefined>, message?: string): void;
addStreamingAssert(field: keyof OUT, fn: (chunk: string, done?: boolean) => boolean | string | undefined | Promise<boolean | string | undefined>, message?: string): void;
addFieldProcessor(field: keyof OUT, fn: (value: any) => any): void;
addStreamingFieldProcessor(field: keyof OUT, fn: (chunk: string, ctx: any) => void): void;
stop(): void;
}
class AxAgent<IN, OUT> {
forward(ai: AxAIService, values: IN, options?: AxAgentOptions): Promise<OUT>;
streamingForward(ai: AxAIService, values: IN, options?: AxAgentOptions): AsyncGenerator<{ delta: Partial<OUT> }>;
getFunction(): AxFunction;
}
class AxFlow<IN, OUT> {
node(name: string, signature: string | AxSignature): AxFlow;
execute(name: string, mapper: (state) => any): AxFlow;
returns(mapper: (state) => OUT): AxFlow;
forward(ai: AxAIService, values: IN): Promise<OUT>;
}
```
## Event-Driven Programs
Use `eventRuntime()` when notifications, webhooks, timers, or remote tasks
should wake or resume an Ax program. Sources publish into an inbox; explicit
routes choose `observe`, `invalidate`, `wake`, or `resume`. Event payloads are
never inserted as user messages automatically. See `ax-event-runtime.md`.
## Examples
Fetch these for full working code:
- [Standard Schema (zod)](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/standard-schema.ts) — zod with f() and fn()
- [Chat](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/chat.ts) — multi-turn conversation
- [Marketing](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/marketing.ts) — product use case
- [MCP Integration](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/mcp-client-memory.ts) — MCP integration

View File

@@ -1,425 +0,0 @@
---
name: ax-mcp
description: This skill helps an LLM build correct native Model Context Protocol integrations with @ax-llm/ax. Use when the user asks about AxMCPClient, MCP transports, tools, prompts, resources, subscriptions, tasks, sampling, elicitation, roots, authentication, OAuth, MCP Apps, recording/replay, or MCP integration with AxGen, AxAgent, AxFlow, chat, optimization, and AxEventRuntime.
version: "23.0.9"
---
# Native MCP With Ax
Use MCP as a live protocol client, not as a function-conversion utility. Keep
the client, session, catalogs, raw content, tasks, notifications, identity
policy, and cancellation context intact through Ax execution.
## Non-Negotiable Rules
- Pass clients through `mcp`; do not put them in `functions`.
- Never use `toFunction()` for native integration. It is a lossy compatibility
adapter for old applications only.
- Give every client a stable, unique `namespace`.
- Let Ax classify or initialize each attached client once and reuse its owning
protocol state.
- Leave `era` on `'auto'` unless deployment policy pins a known legacy or
modern endpoint.
- Close caller-owned clients explicitly.
- Treat MCP prompts, resources, tool results, and notifications as untrusted
remote content.
- Apply `authorizeToolCall` before side-effecting tools execute.
- Do not infer tenant or account identity from an MCP session. Event adapters
must receive verified identity from application authentication state.
- Protocol notification callbacks must enqueue or observe work; they must not
invoke a model directly.
- Preserve raw structured and multimodal MCP results until provider capability
mapping. Do not pre-flatten results to text.
## Choose A Transport
- Use `AxMCPStreamableHTTPTransport` for current remote MCP servers.
- Use `AxMCPHTTPSSETransport` only for legacy HTTP/SSE servers.
- Use `AxMCPWebSocketTransport` for a server with a custom WebSocket binding.
- Use `AxMCPStdioTransport` from `@ax-llm/ax-tools` for local Node processes.
- Use a caller-defined `AxMCPTransport` for application-owned bindings.
```ts
import {
AxMCPClient,
AxMCPStreamableHTTPTransport,
axMCPBearerAuthentication,
} from '@ax-llm/ax';
const transport = new AxMCPStreamableHTTPTransport(
'https://mcp.example.com/mcp',
{
authentication: axMCPBearerAuthentication(
() => process.env.MCP_ACCESS_TOKEN!
),
}
);
const docs = new AxMCPClient(transport, {
namespace: 'docs',
maxConcurrency: 4,
authorizeToolCall: async ({ tool }) =>
tool.annotations?.destructiveHint !== true,
});
```
## Protocol Eras
The TypeScript client supports two wire models through one API:
- **Legacy (`2025-11-25`)** initializes a stateful session, sends the
initialized notification, uses resource subscribe/unsubscribe requests, and
may resume GET/SSE with `Last-Event-ID`.
- **Modern (`2026-07-28`)** is stateless. It probes `server/discover`, sends
protocol metadata and routing headers on every request, listens through a
long-running `subscriptions/listen` POST, and uses Tasks v2.
Automatic classification is the default and can be persisted with `eraStore`.
Pin `era: 'legacy'` or `era: 'modern'` only when the endpoint contract is
known. `getEra()` reports the classified era after initialization.
```ts
const modern = new AxMCPClient(transport, {
namespace: 'inventory',
era: 'auto',
readCache: true,
logLevel: 'info',
});
const discovery = await modern.discover();
console.log(modern.getEra(), discovery.supportedVersions);
```
`discover()` performs initialization/classification but returns only for a
modern endpoint. For era-neutral application code, use `inspectCatalog()`; it
works in both eras.
For local stdio:
```ts
import { AxMCPClient } from '@ax-llm/ax';
import { AxMCPStdioTransport } from '@ax-llm/ax-tools';
const stdio = new AxMCPStdioTransport({
command: 'node',
args: ['./server.mjs'],
});
const local = new AxMCPClient(stdio, { namespace: 'local' });
```
`AxMCPStdioTransport` owns its child process. After `local.close()`, also call
`stdio.terminate()` until the stdio transport exposes the common `close()`
lifecycle directly.
## Attach MCP To AxGen
Attach clients in constructor or forward options. Per-call options override
instance defaults.
```ts
const gen = ax('question:string -> answer:string', { mcp: docs });
const result = await gen.forward(llm, { question }, {
mcpContext: [
{ client: 'docs', resource: { uri: 'docs://guide' } },
],
});
```
`mcpContext` resolves selected prompts or resources before the first model
call and adds attributed, untrusted context. Native tool calls retain client
identity and raw MCP results in memory. `streamingForward()` keeps Ax output
streaming separate from MCP progress and task events.
## Attach MCP To AxAgent
```ts
const assistant = agent('query:string -> answer:string', {
mcp: [docs, search],
mcpInheritance: 'all',
functionDiscovery: true,
contextFields: [],
});
```
Agents expose native modules under `mcp.<namespace>`:
```text
mcp.docs.tools.<tool>
mcp.docs.prompts.list()
mcp.docs.prompts.get(name, args)
mcp.docs.resources.list()
mcp.docs.resources.templates()
mcp.docs.resources.read(uri)
mcp.docs.resources.subscribe(uri)
mcp.docs.resources.unsubscribe(uri)
mcp.docs.tasks.get(taskId)
mcp.docs.tasks.cancel(taskId)
mcp.docs.complete(...)
```
Modern modules also expose task input/update behavior through the client.
`tasks.list()` and `tasks.result()` are legacy task-draft compatibility APIs and
reject modern servers.
Use `mcpInheritance: 'all'`, `'none'`, or a namespace allowlist. The resulting
live execution context propagates through Agent stages, `llmQuery`, RLM, and
child programs. Large catalogs participate in Agent discovery; do not copy
their tools into an inline `functions` array.
## AxFlow And High-Level Chat
Pass `mcp` in Flow defaults or forward options. Nested nodes inherit the same
execution context unless `mcpInheritance` restricts it. Parallel nodes share
the client while respecting its concurrency limit and abort signal.
Use `axMCPChat(ai, request, { mcp })` for a high-level non-streaming native MCP
tool loop. Do not build a second ad-hoc tool dispatcher around `ai.chat()`.
## Catalogs And Raw Operations
An endpoint is only the server address. The server owns tool names, prompt
names, resource names, resource URIs, and URI templates. Discover one cloned
snapshot before asking users to configure identifiers:
```ts
const catalog = await docs.inspectCatalog();
console.log(catalog.tools);
console.log(catalog.prompts);
console.log(catalog.resources);
console.log(catalog.resourceTemplates);
const prompt = await docs.getPrompt('review', { topic: 'MCP' });
const resource = await docs.readResource('docs://guide');
const completion = await docs.complete(reference, argument);
```
`inspectCatalog({ refresh: true })` forces fresh bounded pagination. Snapshot
mutation cannot change the live client. List-change notifications refresh the
catalog revision, and native Ax model steps rebuild tool definitions when that
revision changes. Concrete resources can be selected immediately. URI
templates are discoverable but never expanded automatically; applications
construct an authorized concrete URI and may use MCP completion to suggest
argument values.
## Multi Round-Trip Requests
Modern tool calls, prompt reads, and resource reads may return
`resultType: 'input_required'`. Ax fulfills the embedded roots, sampling, or
elicitation requests through the same host handlers used by legacy server
requests, then repeats the original operation with the latest input responses
and byte-exact `requestState`.
The generated Python, Java, C++, Go, and Rust clients fulfill roots and
host-callback elicitation in modern MRTR rounds. They advertise elicitation only
when a real handler is installed, never advertise sampling, and reject a truthy
sampling option during initialization. Legacy inbound elicitation and MRTR
sampling remain TypeScript-only.
Configure only handlers the host can enforce. Ax limits the loop to five input
rounds by default; use `maxInputRounds` for a stricter policy. A missing handler
or exhausted round limit is a protocol error. The tool concurrency slot stays
held throughout all rounds.
## Tasks, Progress, And Cancellation
Modern Tasks v2 are server-directed. `callTool()` auto-awaits an unsolicited
task by default, so Ax tool bindings keep returning the final tool result. Use
`callToolOutcome()` or `taskHandling: 'expose'` when the application needs the
task handle, and answer `input_required` work with `provideTaskInput()`:
```ts
const outcome = await docs.callToolOutcome('reindex', { scope: 'all' });
if (outcome.kind === 'task') {
const task = await docs.getTask(outcome.task.taskId);
if (task.status === 'input_required') {
await docs.provideTaskInput(task.taskId, {
approval: { action: 'accept', content: { approved: true } },
});
}
if (task.status === 'working') await docs.cancelTask(task.taskId);
}
```
Use `subscribeTaskStatus` or `subscribeEvents` for observation. Keep polling
available because task notifications are optional. Pass Ax abort signals
through program execution; never blindly replay a tool call after an uncertain
post-side-effect failure.
`callToolTask()`, `listTasks()`, and `getTaskResult()` remain functional only
for legacy task-draft servers and are deprecated. Modern completed results and
errors are embedded in `tasks/get`.
## Subscriptions And Event-Driven Agents
Use `AxMCPEventSource` with `AxEventRuntime`. A subscription callback only
publishes an event into the inbox. Explicit routes decide whether to observe,
invalidate, wake, or resume.
```ts
const source = new AxMCPEventSource({
client: docs,
resourceSubscriptions: {
select: (resource) =>
resource.mimeType === 'text/markdown' &&
resource.name === 'Engineering guide',
},
identity: { tenantId: 'tenant-1' },
trust: 'authenticated',
});
const runtime = eventRuntime({
allowVolatile: true,
sources: [source],
routes: [
...axMCPEventRoutes({ client: docs }),
eventRoute('guide-updated')
.types('mcp.resource.updated')
.authenticated()
.wake(
eventTarget('reviewer')
.program(reviewer)
.ai(llm)
.input((input) =>
input.field('uri', eventPath.data('uri'))
)
.build()
)
.build(),
],
});
```
Safe defaults are:
- omitted resource policy -> subscribe to no resources
- `'all'` -> explicitly subscribe to all discovered concrete resources
- URI array -> explicitly subscribe to application-constructed concrete URIs
- selector -> choose concrete resources by name, URI, description, MIME type,
annotations, or the surrounding catalog
- catalog changes -> `invalidate`
- progress and logging -> `observe`
- resource updates -> no implicit wake
- `input_required` and terminal task states -> resume the owning continuation
The signature-aware input plan is the data boundary. Raw event data remains in
`eventContext`; only fields selected with segment-safe `eventPath` descriptors
become program inputs. Use multiple matching routes to fan one notification out
to multiple Agents with independent authorization and run records.
Managed sources refresh and diff their selection after
`notifications/resources/list_changed`. They keep the prior selection if a
selector throws, retain successful wire transitions after a partial failure,
and retry incomplete work on the next change or reconnect. The client tracks a
separate logical owner for manual subscriptions, every source, and restored
intent: only the first owner sends `resources/subscribe`, and only the last
release sends `resources/unsubscribe`. Closing a source cannot break another
owner. Closing the client terminates all ownership and transport state.
Listening is era-aware. Legacy clients maintain resource subscriptions with
`resources/subscribe` and resume a GET/SSE stream with `Last-Event-ID` when the
server supports it. Modern clients place catalog interests, concrete resource
URIs, and known task IDs in `subscriptions/listen`; changes restart that POST
stream with a fresh request ID and no resume header. In both eras,
`startListening()` is nonblocking and returns a handle with `ready`, `done`,
and `close()`.
For the detailed lifecycle and troubleshooting guide, read
`docs/MCP_SUBSCRIPTIONS.md` and use the checked-in six-language MCP examples.
## Server-Initiated Requests
Configure handlers on `AxMCPClient` when advertising the corresponding client
capability:
- `sampling` for `sampling/createMessage`
- `elicitation` for form or URL elicitation
- `roots` for `roots/list`
- `onProgress`, `onLoggingMessage`, and `onTaskStatus` for observation
Do not advertise a client capability without a working host handler and policy.
## Authentication And OAuth
For simple authentication, compose `axMCPBearerAuthentication`,
`axMCPBasicAuthentication`, `axMCPAPIKeyAuthentication`,
`axMCPHMACAuthentication`, or a caller-defined strategy in the HTTP transport.
Use the transport `oauth` option for protected-resource discovery, PKCE,
client metadata or dynamic registration, refresh, challenge-driven scope
step-up, DPoP, PAR/JAR/RAR, mTLS, revocation, introspection, client credentials,
or enterprise-managed authorization. Supply persistent token and registration
stores in distributed deployments. Never serialize tokens into Ax program or
event state.
Generated Python, Java, C++, Go, and Rust transports implement the portable
OAuth middle tier: RFC 9728 well-known and challenge discovery, RFC 8414/OIDC
authorization-server metadata, PKCE S256 authorization-code exchange,
RFC 8707 resource binding, refresh with a 60-second skew, client credentials,
and RFC 9207 `iss`. Configure the language's `AxMCPOAuthOptions` with
`clientId`, an endpoint-keyed `tokenStore`, `onAuthCode`, and `requireIss`.
The host callback receives an authorization URL and returns `code`, `state`,
and `iss`; it owns browser or headless interaction. `none` and
`client_secret_post` are the only generated-port client authentication modes.
Do not suggest TypeScript-only DPoP, CIMD/DCR, JAR, PAR, RAR, mTLS,
revocation/introspection, JWT validation, or enterprise-managed authorization
for a generated-language client. Use `npm run test:mcp-oauth` as the
credential-free cross-language gate. See `docs/MCP_UCP.md` for compact
per-language configuration snippets.
Keep SSRF protection enabled for remote discovery and redirect handling. Relax
loopback or HTTP restrictions only for controlled local development.
For checked-in Streamable HTTP examples, set `AX_MCP_ENDPOINT`. A localhost
TypeScript demo must opt in explicitly with
`ssrfProtection: { allowHTTP: true, allowLoopback: true }`; never copy that
configuration to a remote endpoint. Generated examples use their equivalent
`requireHttps` / `allowLocalhost` / `allowPrivateNetworks` fields only for
`127.0.0.1`.
For a real local check, run `src/examples/mcp-event-demo-server.ts`, set
`AX_MCP_ENDPOINT=http://127.0.0.1:3001/mcp`, and trigger
`/control/resource` or `/control/task/complete`. The mandatory credential-free
matrix is `npm run test:mcp-events:generated`; provider-backed examples are
advisory and require their documented API key.
## MCP Apps And Extensions
Negotiate official extensions through client capabilities. Use
`AxMCPAppBridge` for MCP App resources and host messages; enforce CSP,
permissions, visibility, allowed tools, and untrusted model-context policy.
Do not render arbitrary HTML returned from a normal tool result as an MCP App.
## Recording, Replay, And Evaluation
Wrap a real transport with `AxMCPRecordingTransport` to capture deterministic
protocol interactions. Use `AxMCPReplayTransport` for tests, optimization, and
evaluation. Live MCP evaluation is rejected by default because repeated model
runs could repeat external side effects; opt in only deliberately.
## Testing Checklist
- Use a local deterministic protocol server or replay transport.
- Assert namespace and tool collisions fail before model execution.
- Test raw text, image, audio, resource-link, embedded-resource, metadata,
task, and error results.
- Test catalog changes during a multi-step run.
- Test authorization denial before transport execution.
- Test subscription reconnect and logical resubscription.
- Test anonymous events cannot match authenticated routes.
- Test terminal task events resume only the owning identity and correlation.
- Test cancellation and uncertain outcomes without duplicate side effects.
- Close clients, listening handles, runtimes, and local servers in `finally`.
Generated transports currently supervise legacy long-lived GET/SSE connections
and resume with `Last-Event-ID` when available. Generated event runtimes remain
host-driven: schedule delayed work with `nextDueAt()` and `runDue()`. Rust hosts
also call `AxMCPEventSource.poll()` to drain protocol callbacks on the host
thread. Close the source/runtime before the caller-owned client so unsubscribe
and cancellation messages can still be sent.
For generic inbox, continuation, store, and sink behavior, use the
`ax-event-runtime` skill. For program-specific behavior, combine this skill with
`ax-gen`, `ax-agent`, or `ax-flow`.

View File

@@ -1,125 +0,0 @@
---
name: ax-playbook
description: This skill helps an LLM generate correct playbook code using @ax-llm/ax. Use when the user asks about playbook(), AxPlaybook, context playbooks, evolving context, ACE / Agentic Context Engineering, agent.playbook(), or growing/applying task knowledge offline and online with evolve() and update().
version: "23.0.9"
---
# Playbook Codegen Rules (@ax-llm/ax)
Use this skill to generate context-playbook code. A playbook grows an evolving body of task knowledge and renders it into a program's context. The evolution engine (ACE — Agentic Context Engineering) is hidden behind `playbook(...)`, exactly as `optimize(...)` hides its optimizer. Prefer the `playbook(...)` concept; only reach for `AxACE` directly when the user explicitly wants the low-level engine.
## Use These Defaults
- Create with `playbook(program, { studentAI, teacherAI? })`; it returns an `AxPlaybook` handle.
- Grow offline with `await pb.evolve(examples, metric)` — returns `{ bestScore, playbook }`.
- Grow online with `await pb.update({ example, prediction, feedback })` — no metric needed.
- Apply with `pb.applyTo(program)` (defaults to the bound program).
- Persist with `pb.toJSON()` and restore with `playbook(program, opts).load(snapshot)`.
- Inspect with `pb.render()` (markdown) and `pb.getState()` (`{ playbook, artifact }`).
- For agents use `agent.playbook({ target: 'actor' | 'responder' })`; default target is `'actor'`.
- Use a cheaper `studentAI` to run the program and an optional stronger `teacherAI` to reflect/curate.
- Prefer `ai()`, `ax()`, and `agent()` for new code.
## Critical Rules
- `playbook(...)` binds to an `AxGen` program; `evolve`/`update` need that program's signature.
- `evolve()` returns only `{ bestScore, playbook }`. There is no Pareto front and no `optimizedProgram` — that is `optimize(...)`'s shape, not a playbook's.
- `update({ example, prediction, feedback })` requires the full `{ example, prediction }`; `example` must match the program's input fields (plus any expected output). Do not pass bare input fields at the top level.
- `update()` works without a prior `evolve()`/`load()` — the handle hydrates lazily on first use.
- `applyTo()` injects a `## Context Playbook` block into the program description; calling it repeatedly recomposes from the original base (no stacking).
- Keep the offline `metric` deterministic and cheap, like a GEPA metric.
- A playbook is plain JSON. Persist `pb.toJSON()` and `load(...)` it into a fresh program for production.
- The playbook engine, construction-time agent attachment, failure harvesting,
and verified agent evolution are available in TypeScript and the generated
Python, Java, C++, Go, and Rust packages. Use each package's native casing and
callback types.
## Offline Pattern (evolve)
```typescript
import { type AxMetricFn, ai, ax, playbook } from '@ax-llm/ax';
const program = ax('review:string -> sentiment:class "positive, negative"');
const studentAI = ai({ name: 'openai', apiKey: process.env.OPENAI_APIKEY! });
const metric: AxMetricFn = ({ prediction, example }) =>
(prediction as any).sentiment === (example as any).sentiment ? 1 : 0;
const pb = playbook(program, { studentAI, maxEpochs: 2 });
const { bestScore } = await pb.evolve(train, metric);
pb.applyTo(program);
```
## Online Pattern (update)
```typescript
// After a real run, feed the outcome back so the playbook keeps learning.
await pb.update({
example: { review: 'Five stars, would buy again.' },
prediction: { sentiment: 'negative' },
feedback: 'WRONG: enthusiastic praise is positive.',
});
pb.applyTo(program);
```
## Persist And Restore
```typescript
const snapshot = pb.toJSON(); // { playbook, artifact } — plain JSON
// later, in another process / a production program instance:
playbook(prodProgram, { studentAI }).load(snapshot).applyTo(prodProgram);
```
## Agents
`a.playbook({ target })` returns an agent-aware `AxAgentPlaybook` (the stage `AxPlaybook` handle plus an agent-level `evolve`). The one playbook the agent renders into its prompt grows three ways:
- Continuous (trust): the construction-time `playbook` option (see `ax-agent`) harvests each run's failures automatically — no dataset.
- On-demand (trust): `apb.update({ example, prediction, feedback })`.
- Batch verified (proof): `apb.evolve(dataset, options)` runs the full agent over a task set, mines failure clusters, and proposes one playbook bullet per weakness; with `verify` (default on) it keeps a bullet only if held-in improves AND the `validation` held-out set does not regress, else exact rollback. `verify: false` = trust-batch. Bullets-only.
```typescript
const a = agent('ticket:string -> reply:string', { ai });
const apb = a.playbook({ target: 'actor' }); // agent-aware handle; 'actor' (default) or 'responder'
await apb.update({ example, prediction, feedback }); // online: injected into the live stage prompt
const result = await apb.evolve(
{ train, validation }, // AxAgentEvalDataset
{ metric, runsPerTask: 2 }, // verify:true by default
);
```
The agent-level `evolve(dataset, options)` is distinct from the program-level `pb.evolve(examples, metric)` above: it takes an `AxAgentEvalDataset` plus options, runs the whole pipeline, and returns baseline/final held-in & held-out with per-bullet outcomes (no `{ bestScore }`). For full-pipeline tuning of agent instructions and demos (not the playbook) use `agent.optimize(...)` (GEPA).
Generated packages expose that same agent-bound loop with language-shaped APIs:
| Language | Agent-bound evolve call |
|---|---|
| Python | `agent.playbook().evolve(dataset, options)` |
| Java | `agent.playbook(null).evolve(dataset, options)` |
| C++ | `agent.get_playbook()->evolve(dataset, options)` |
| Go | `agent.GetPlaybook().EvolveAgent(ctx, dataset, options)` |
| Rust | `playbook.evolve_agent(&mut agent, client, dataset, options)` |
All five generated packages thread structured `failureSignals` through agent
evaluation predictions. The default verify gate accepts a proposed bullet only
when held-in score improves and held-out score stays within `epsilon`; rejection
restores the exact prior snapshot. Scoring is host-shaped: TypeScript uses its
metric, Python/Java/Go can accept a metric callback, and all generated ports can
use task `score`/`scores` values plus the agent evaluation result.
## Playbook vs optimize()
- `playbook(...)` — accumulate reusable, evolving task knowledge; the only path that also learns online via `update(...)`.
- `optimize(...)` / `agent.optimize(...)` — tune instruction text and few-shot demos offline to a best/Pareto result.
- They are complementary; a project can use both.
## Troubleshooting
- "Cannot convert undefined or null to object" from `update()` → you passed input fields at the top level; wrap them in `example: { ... }`.
- Empty playbook after `evolve()` → the model already scored well, so nothing was curated; use harder/ambiguous examples or a weaker `studentAI` to surface lessons.
- Playbook not affecting an agent's behavior → ensure `apply` is not `false` and you used `agent.playbook(...)` (not a bare `playbook()` on an internal program).
## See Also
- `ax-gepa` - `optimize(...)` and `AxGEPA` for instruction/demo tuning.
- `ax-agent-context` - choosing between contextMap, contextPolicy, `agent.playbook(...)`, and recall.
- `ax-agent-optimize` - `agent.optimize(...)` GEPA tuning for agents.

View File

@@ -1,81 +0,0 @@
---
name: ax-refine
description: Use this skill when writing or reviewing Ax bestOfN/refine code, reward functions, thresholds, native sample selection, serial attempts, generated advice, and attempt diagnostics.
version: "23.0.9"
---
# Ax Refine And BestOfN
Use `bestOfN(...)` when you can score complete outputs independently. Use `refine(...)` when failed rounds should produce feedback that changes the next attempt.
## Validation And Assertions
Keep reward scoring, whole-output assertions, and streaming assertions separate:
- Use schema validation for shape, types, and field-level constraints.
- Use `addAssert(...)` for whole-output hard invariants. Failed assertions feed correction text into the normal retry loop.
- Use `addStreamingAssert(...)` for partial streaming hard invariants. It aborts the current stream attempt as soon as the partial field fails, then feeds correction text into the normal retry loop.
- Use `bestOfN(...)` for complete-candidate selection.
- Use `refine(...)` for reward-scored retry rounds with generated feedback.
## APIs
```typescript
import { bestOfN, refine } from '@ax-llm/ax';
const selected = bestOfN(program, {
n: 4,
threshold: 0.8,
rewardFn: ({ input, prediction, traces, chatLog }) => score(prediction),
});
const improved = refine(program, {
rounds: 3,
samplesPerRound: 2,
threshold: 0.85,
rewardDescription: 'Prefer complete, grounded, concise answers.',
rewardFn: ({ prediction }) => score(prediction),
});
```
Rules:
- `forward(...)` returns the selected prediction.
- `streamingForward(...)` is unsupported; score complete outputs instead.
- `getUsage()` aggregates usage across attempts.
- `getTraces()` and `getChatLog()` return the selected attempt's diagnostics.
- `getAttempts()` returns all attempt metadata, including reward, errors, and advice application.
## Reward Functions
Reward functions return a number. Higher is better. A `threshold` marks a good-enough candidate and can stop serial attempts early.
```typescript
const rewardFn = ({ prediction }) => {
const exact = prediction.answer === 'Paris' ? 1 : 0;
const concise = prediction.answer.length < 80 ? 0.2 : 0;
return exact + concise;
};
```
Use serial strategy when the reward needs traces, chat logs, tools, or full flow behavior.
## Strategies
- `strategy: "auto"` uses native samples for `AxGen` and serial attempts for composite programs.
- `strategy: "native-samples"` uses `sampleCount` and a reward-backed `resultPicker`; candidate context includes outputs, not full per-candidate traces.
- `strategy: "serial"` runs isolated full-program attempts with fresh memory/session IDs.
## Refine Advice
`refine(...)` generates advice after a below-threshold round. Advice is appended temporarily to matching `kind: "instruction"` components exposed by `getOptimizableComponents()` and applied through `applyOptimizedComponents()`.
Rules:
- Original instruction values are restored in `finally`, on success and error.
- Programs without instruction components continue as best-of-N rounds and mark `adviceApplied: false`.
- Do not add DSPy-style `hint_` signature fields; Ax uses instruction-component advice.
## Streaming
Do not use `refine(...)` for streaming. For partial-output safety, use `addStreamingAssert(fieldName, fn, message?)` on `AxGen`. Streaming assertions fail fast within the current attempt with `AxStreamingAssertionError`, then retry with correction feedback when retries remain.

View File

@@ -1,421 +0,0 @@
---
name: ax-signature
description: This skill helps an LLM generate correct DSPy signature code using @ax-llm/ax. Use when the user asks about signatures, s(), f(), field types, string syntax, fluent builder API, validation constraints, or type-safe inputs/outputs.
version: "23.0.9"
---
# Ax Signature Reference
## Signature Syntax
```
[description] input1:type, input2:type -> output1:type, output2:type
```
## Field Types
| Type | Syntax | TypeScript | Example |
|------|--------|-----------|---------|
| String | `:string` | `string` | `userName:string` |
| Number | `:number` | `number` | `score:number` |
| Boolean | `:boolean` | `boolean` | `isValid:boolean` |
| JSON | `:json` | `any` | `metadata:json` |
| Date | `:date` | `Date` | `birthDate:date` |
| DateTime | `:datetime` | `Date` | `timestamp:datetime` |
| DateRange | `:dateRange` | `{ start: Date; end: Date }` | `travelDates:dateRange` |
| DateTimeRange | `:datetimeRange` | `{ start: Date; end: Date }` | `meetingWindow:datetimeRange` |
| Image | `:image` | `{mimeType, data}` | `photo:image` (input only) |
| Audio | `:audio` | input: `AxAudioInput`; output: `AxChatAudioOutput` | `recording:audio`, `speech:audio` |
| File | `:file` | `{mimeType, data}` | `document:file` (input only) |
| URL | `:url` | `string` | `website:url` |
| Code | `:code` | `string` | `pythonScript:code` |
| Class | `:class "a, b, c"` | `"a" \| "b" \| "c"` | `mood:class "happy, sad"` |
Date, datetime, and range fields are AI-friendly but strict. They accept ISO-style values, trim minor whitespace/casing issues, and parse ranges as `{ "start": "...", "end": "..." }`, `[start, end]`, `start/end`, or natural delimiters like `start to end`; invalid values and reversed ranges should fail validation rather than being silently autocorrected.
## Arrays, Optional, and Internal Fields
```typescript
'tags:string[] -> processedTags:string[]' // arrays
'query:string, context?:string -> response:string' // optional with ?
'problem:string -> reasoning!:string, solution:string' // internal with !
```
## Extended String Grammar (Modifier Bags + Nested Objects)
The string form is constraint-complete: everything the fluent API expresses
(except Standard Schema fields) can be written in the string. A type takes an
optional comma-separated, order-free **modifier bag** in parentheses, and
objects declare structured fields inline.
```typescript
`userAge:number(min 0, max 120), contactEmail:string(format email, cache), codeSnippet:code(python)
-> userName:string(pattern "^[a-z_]+$" "lowercase name"), tagList:string(item "a short tag")[] "all tags",
profileList:object{ fullName:string, userAge?:number(min 0) }[] "matched profiles"`
```
| Modifier | Applies to | Effect |
|----------|-----------|--------|
| `min N` / `max N` | `string`, `number` | String length bounds / numeric value bounds |
| `format email\|uri\|date\|date-time` | `string` | Format validation |
| `pattern "regex" ["desc"]` | `string` | Regex validation with optional description |
| `cache` | top-level input | Prefix-cache breakpoint |
| `item "desc"` | arrays | Per-item description: `tags:string(item "a tag")[]` |
| `<language>` | `code` | Language of the snippet: `snippet:code(python)` |
- `object{ field:type, opt?:type }` nests recursively; append `[]` for an array of objects.
- Optional goes on the **name** (`userAge?:number`), never after the type.
- The string API is **strict**: a modifier that does not apply to its type (e.g. `min` on a boolean) is a parse error, where the fluent API silently ignores it.
- Inside `object{ ... }`, the `!` internal marker, media types, `cache`, and `item` are rejected (they only apply at the top level).
- In quoted values, backslashes are doubled — a regex `\d` is written `pattern "\\d+"`.
- `AxSignature.toString()` renders every construct back to this grammar losslessly, so a signature round-trips — this is what lets a whole flow serialize its node contracts into mermaid `%%ax` directives (see the ax-flow skill).
## Signature Gallery
Real-world contracts, one line each — every entry below parses with `s()` as written (`#` lines are captions, not part of the signature):
```text
# Support triage: several class outputs plus a capped reply draft
ticketText:string -> priorityClass:class "p0, p1, p2", sentimentClass:class "angry, neutral, happy", replyDraft:string(max 500)
# Invoice extraction: regex-validated id, bounded totals, structured line items
invoiceText:string -> invoiceNumber:string(pattern "^INV-\\d+$" "INV- then digits"), totalAmount:number(min 0), lineItems:object{ description:string, quantity:number(min 1), unitPrice:number }[]
# Contact enrichment: optional format-validated outputs
bioText:string -> contactEmail?:string(format email), websiteUrl?:string(format uri), birthDate?:string(format date)
# RAG: cached corpus input plus per-item described citations
corpusText:string(cache), userQuestion:string -> answerText:string, citedChunks:string(item "verbatim quote")[]
# Code generation: language-tagged code outputs
taskBrief:string -> pythonScript:code(python), testCases:code(python), riskNotes?:string
# Chain of thought: internal reasoning stripped from the result
problemText:string -> reasoning!:string, solutionText:string
# Resume parsing: nested objects inside nested arrays
resumeText:string -> candidateProfile:object{ fullName:string, yearsExperience:number(min 0), skillList:string[], education:object{ schoolName:string, degreeName?:string }[] }
# Lead scoring: signature-level description, bounded score, class next step
"Score sales leads" leadNotes:string -> fitScore:number(min 0, max 100) "0-100 fit", nextStep:class "call, email, drop"
# Multimodal: top-level image input with an optional question
productPhoto:image, question?:string -> productDescription:string, detectedObjects:string[]
# Meeting audio: audio input, capped summary, per-item action list
meetingAudio:audio -> meetingSummary:string(max 1000), actionItems:string(item "one action item")[]
# Moderation: class verdict plus structured flagged spans
postText:string -> moderationVerdict:class "allow, review, block", flaggedSpans:object{ spanText:string, reasonNote:string }[]
# Translation: optional locale input
sourceText:string, targetLocale?:string -> translatedText:string, glossaryHits:string[]
# Text-to-SQL: cached schema plus SQL-tagged output
schemaText:string(cache), questionText:string -> sqlQuery:code(sql), queryNotes?:string(max 200)
# Calendar extraction: datetime fields and an optional end
emailText:string -> eventTitle:string, startsAt:datetime, endsAt?:datetime, attendeeNames:string[]
# Booking window: date range, bounded party size, and flexibility flag
requestText:string -> stayWindow:dateRange, partySize:number(min 1, max 12), flexibleDates:boolean
# Contract dates: date fields plus bounded notice period
contractText:string -> effectiveDate:date, expiryDate?:date, autoRenews:boolean, noticeDays?:number(min 0)
# Link audit: URL arrays and an optional primary URL
pageText:string -> referencedUrls:url[], primaryUrl?:url
# Config generation: JSON output plus per-item warnings
requirementsText:string -> serviceConfig:json, setupWarnings:string(item "one warning")[]
# Claims gate: cached policy, bounded confidence, and optional citation
claimText:string, policyText:string(cache) -> isCovered:boolean, confidenceScore:number(min 0, max 1), citedClause?:string
# Earnings extraction: structured period data plus a class outlook
filingText:string(cache) -> revenueByPeriod:object{ periodLabel:string, amountUsd:number }[], guidanceTone:class "raise, hold, cut"
# Pull request review: diff code, cached guide, structured comments, and verdict
diffText:code(diff), styleGuide?:string(cache) -> reviewComments:object{ filePath:string, lineNumber:number(min 1), commentText:string(max 300) }[], overallVerdict:class "approve, revise"
# Incident triage: severity class, optional service, and per-item runbook steps
alertLog:string -> incidentSeverity:class "sev1, sev2, sev3", suspectedService?:string, runbookSteps:string(item "one step")[]
# Product listing: image and file inputs with constrained listing outputs
productPhoto:image, priceSheet?:file -> listingTitle:string(max 80), bulletPoints:string(item "one selling point")[], priceUsd?:number(min 0)
# Study cards: nested object array with an optional difficulty tag
chapterText:string -> flashCards:object{ questionText:string, answerText:string, difficultyTag?:string }[]
```
## Four Ways to Create Signatures
### 1. String-Based (Recommended for simple cases)
```typescript
import { ax, s } from '@ax-llm/ax';
const gen = ax('input:string -> output:string');
const sig = s('query:string -> response:string');
```
### 2. Pure Fluent Builder API
```typescript
import { f } from '@ax-llm/ax';
const sig = f()
.input('userMessage', f.string('User input'))
.input('contextData', f.string('Additional context').optional())
.input('tags', f.string('Keywords').array())
.output('responseText', f.string('AI response'))
.output('confidenceScore', f.number('Confidence 0-1'))
.output('debugInfo', f.string('Debug info').internal())
.build();
```
### 3. Standard Schema (zod / valibot / arktype)
`.input()` and `.output()` accept any [Standard Schema v1](https://standardschema.dev) compatible library — no wrapper, no adapter. Three shapes work everywhere:
```typescript
import { z } from 'zod';
import { f } from '@ax-llm/ax';
// Shape A: per-field schema — name first, then the schema, then optional ax hints
const sig = f()
.input('contextData', z.string().describe('Background context'), { cache: true })
.input('userQuestion', z.string().describe('Question to answer'))
.output('reasoning', z.string().describe('Step-by-step thinking'), { internal: true })
.output('answer', z.string().describe('Final answer'))
.build();
// Shape B: whole-object schema — decomposed into fields in declaration order
const sig2 = f()
.description('Answer questions from retrieved context')
.input(
z.object({
contextData: z.string().describe('Background context'),
userQuestion: z.string().describe('Question to answer'),
}),
{ fields: { contextData: { cache: true } } } // companion options map
)
.output(
z.object({
reasoning: z.string().describe('Step-by-step thinking'),
answer: z.string().describe('Final answer'),
}),
{ fields: { reasoning: { internal: true } } }
)
.build();
```
Validation constraints from zod flow into ax's prompt validation:
```typescript
// String constraints: .email(), .url(), .min(), .max(), .regex()
// Number constraints: .min(), .max()
// Arrays: z.array(z.string())
// Enums: z.enum([...]) — NOTE: enum maps to ax class type, output fields only
const sig3 = f()
.input(z.object({
emailAddress: z.string().email().describe('Contact email'),
username: z.string().min(3).max(20).describe('Handle'),
score: z.number().min(0).max(100).describe('Numeric score'),
}))
.output(z.object({
priority: z.enum(['low', 'medium', 'high']).describe('Priority'),
summary: z.string().describe('Result'),
}))
.build();
```
**Companion options** (`AxFieldOptions`) carry ax-specific hints that schema libraries don't represent:
| Option | Effect |
|--------|--------|
| `{ cache: true }` | Mark input field as a prefix-cache breakpoint |
| `{ internal: true }` | Mark output field as internal scratchpad (stripped from result) |
The same Standard Schema shapes work on `fn()` tools via `.arg()`, `.returns()`, and `.returnsField()` — argument types are inferred from the schema:
```typescript
import { z } from 'zod';
import { fn } from '@ax-llm/ax';
// Whole-object zod on a tool — AI-SDK-style
const lookupProduct = fn('lookupProduct')
.description('Look up a product by name and return its current details')
.arg(
z.object({
productName: z.string().min(1).describe('Exact product name'),
includeSpecs: z.boolean().optional(),
})
)
.returns(
z.object({
price: z.number(),
inStock: z.boolean(),
rating: z.number().min(1).max(5),
})
)
.handler(async ({ productName, includeSpecs }) => ({
price: 79.99,
inStock: true,
rating: 4.3,
}))
.build();
// Per-argument form — mix with f.*() args, attach ax hints
const searchDocs = fn('searchDocs')
.description('Search indexed docs')
.arg('query', z.string().min(1), { cache: true })
.arg('limit', z.number().int().positive().optional())
.returnsField('results', z.array(z.string()))
.handler(async ({ query }) => [])
.build();
```
### 4. Hybrid
```typescript
import { s, f } from '@ax-llm/ax';
const sig = s('base:string -> result:string')
.appendInputField('extra', f.json('Metadata').optional())
.appendOutputField('score', f.number('Quality score'));
```
## Fluent API Reference
Type creators:
- `f.string(desc)`, `f.number(desc)`, `f.boolean(desc)`, `f.json(desc)`
- `f.image(desc)`, `f.audio(desc)`, `f.file(desc)`, `f.url(desc)`
- `f.email(desc)`, `f.date(desc)`, `f.datetime(desc)`, `f.dateRange(desc)`, `f.datetimeRange(desc)`
- `f.class(['a','b','c'], desc)`, `f.code(desc)`
- `f.object({ field: f.string() }, desc)`
Chainable modifiers (method chaining only, no nesting):
- `.optional()` - make field optional
- `.array()` / `.array('list description')` - make field an array
- `.internal()` - output only, hidden from final output
- `.cache()` - input only, mark for prompt caching
```typescript
// Correct: pure fluent chaining
f.string('description').optional().array()
f.string('context').cache().optional()
f.object({ field: f.string() }, 'item desc').array('list desc')
// Wrong: nested function calls (removed)
f.array(f.string('description')) // REMOVED
f.optional(f.string('description')) // REMOVED
f.internal(f.string('description')) // REMOVED
```
## Validation Constraints
### String Constraints
```typescript
f.string('username').min(3).max(20)
f.string('email').email()
f.string('website').url()
f.string('birthDate').date()
f.string('timestamp').datetime()
f.string('pattern').regex('^[A-Z0-9]')
```
### Number Constraints
```typescript
f.number('age').min(18).max(120)
f.number('score').min(0).max(100)
```
### Complete Validation Example
```typescript
const sig = f()
.input('formData', f.string('Raw form data'))
.output('user', f.object({
username: f.string('Username').min(3).max(20),
email: f.string('Email').email(),
age: f.number('Age').min(18).max(120),
bio: f.string('Bio').max(500).optional(),
website: f.string('Website').url().optional(),
tags: f.string('Tag').min(2).max(30).array()
}, 'User profile'))
.build();
```
## Cached Input Fields
```typescript
const sig = f()
.input('staticContext', f.string('Context').cache())
.input('userQuery', f.string('Dynamic query'))
.output('answer', f.string('Response'))
.build();
```
## Field Naming Rules
Good: `userQuestion`, `customerEmail`, `analysisResult`, `confidenceScore`
Bad: `text`, `data`, `input`, `output`, `a`, `x`, `val` (too generic), `1field` (starts with number)
## Media Type Restrictions
- Image and file fields are top-level input fields only.
- Audio fields can be top-level inputs or single top-level outputs.
- Audio output fields are scripted speech artifacts: the model returns plain text, then Ax synthesizes `AxChatAudioOutput`.
- Media fields cannot be nested in objects.
- Media arrays are supported for inputs only; output `audio[]` is not supported.
## Common Patterns
```typescript
// Chain of Thought
'problem:string -> reasoning!:string, solution:string'
// Classification
'email:string -> priority:class "urgent, normal, low"'
// Multi-modal input
'imageData:image, question?:string -> description:string, objects:string[]'
// Scripted speech output
'question:string -> speech:audio, summary:string'
// Data Extraction
'invoiceText:string -> invoiceNumber:string, totalAmount:number, lineItems:json[]'
// Constrained string form (no fluent builder needed)
'reviewText:string(max 2000) -> rating:number(min 1, max 5), themes:string(item "a theme")[]'
// Nested object output in the string form
'profileText:string -> profile:object{ fullName:string, age?:number(min 0) }'
// With description
'"Answer TypeScript questions" question:string -> answer:string, confidence:number'
```
## Critical Rules
- The string form is constraint-complete: reach for modifier bags (`string(max 500)`, `number(min 0, max 10)`, `string(format email)`) and inline `object{ ... }` before switching to fluent/zod just for constraints. Reserve fluent/Standard Schema for zod/valibot-backed fields.
- The string API is strict — a modifier that does not apply to its type is a parse error (the fluent API silently ignores it).
- Use `f()` fluent builder, NOT nested `f.array(f.string())` -- those are removed.
- Field names must be descriptive (not generic like `text`, `data`, `input`).
- Image/file media types are input-only, top-level only; audio may also be a single top-level output.
- `.internal()` / `{ internal: true }` is output-only (for chain-of-thought reasoning).
- `.cache()` / `{ cache: true }` is input-only (for prompt caching).
- Validation errors trigger auto-retry with correction feedback.
- `f.email()`, `f.url()`, `f.date()`, `f.datetime()` are shorthand for `f.string().email()` etc.; `f.dateRange()` and `f.datetimeRange()` return `{ start: Date; end: Date }`.
- `z.enum()` maps to ax's `class` type — only valid on **output** fields.
- For multimodal inputs (images, audio, files) and scripted audio outputs, use `f.image()` / `f.audio()` / `f.file()` — zod has no equivalent.
## Examples
Fetch these for full working code:
- [Standard Schema (zod)](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/standard-schema.ts) — zod with f() and fn(), all three shapes
- [Fluent Signature](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/fluent-signature-example.ts) — native fluent f() API
- [Structured Output](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/structured_output.ts) — structured output with validation
- [Debug Schema](https://raw.githubusercontent.com/ax-llm/ax/refs/heads/main/src/examples/debug_schema.ts) — JSON schema validation

View File

@@ -1,35 +0,0 @@
# Repo-wide ignore for the agents/runner Docker builds.
# Keeps images small and prevents secrets from entering image layers.
node_modules
**/node_modules
**/dist
**/.flue-vite
# Secrets — never bake into an image.
.env
.env.*
!.env.example
**/.env
**/.env.*
# Local Convex / runtime state.
.convex
**/.convex
data
**/data
# VCS, caches, and editor cruft.
.git
.gitignore
**/.DS_Store
**/.vscode
**/.idea
# macOS AppleDouble sidecars are metadata, never TypeScript source.
**/._*
# Workspaces and runner artifacts created at runtime on the host.
workspaces
**/workspaces
*.log

View File

@@ -20,12 +20,10 @@ DAEMON_COMMAND_LEASE_MS=60000
RIVET_ENDPOINT=http://localhost:6420
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token
FLUE_URL=http://127.0.0.1:3585
FLUE_URL=http://localhost:3583
# Agent model provider
AGENT_MODEL_PROVIDER=xiaomi

21
.gitignore vendored
View File

@@ -55,24 +55,3 @@ coverage
tmp
temp
.env.*
.env*
infra/ansible/.gitignore
infra/ansible/ansible.cfg
infra/ansible/README.md
infra/ansible/requirements.yml
infra/ansible/group_vars/staging_vds.example/main.yml
infra/ansible/group_vars/staging_vds.example/vault.yml
infra/ansible/inventory/hosts.example.yml
infra/ansible/playbooks/converge.yml
infra/ansible/roles/backup/handlers/main.yml
infra/ansible/roles/backup/tasks/main.yml
infra/ansible/roles/backup/templates/zopu-backup.service.j2
infra/ansible/roles/backup/templates/zopu-backup.sh.j2
infra/ansible/roles/backup/templates/zopu-backup.timer.j2
infra/ansible/roles/common/tasks/main.yml
infra/ansible/roles/directories/tasks/main.yml
infra/ansible/roles/docker/handlers/main.yml
infra/ansible/roles/docker/tasks/main.yml
infra/ansible/roles/docker/vars/main.yml
infra/ansible/roles/firewall/handlers/main.yml
infra/ansible/roles/firewall/tasks/main.yml

View File

@@ -1,17 +0,0 @@
# Archived implementations and agent reference material are not part of the web build.
repos/
.agents/
.codex/
# Private deployment operations are deployed to the VDS, not Vercel.
deploy/
infra/
docs/
# Local development state and tooling.
.vscode/
coverage/
.env
.env.*
packages/backend/.convex/
.vercel/output/

View File

@@ -35,7 +35,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vercel/react-router": "1.3.1",
"react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",

View File

@@ -1,8 +1,7 @@
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";
export default {
appDirectory: "src",
presets: [vercelPreset()],
ssr: true,
} satisfies Config;

View File

@@ -1,74 +0,0 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { X } from "lucide-react";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { ProviderChips } from "./provider-chips";
import type { GitProviderAccountOption } from "./provider-chips";
import { RepositorySelector } from "./repository-selector";
export const AddProjectPanel = ({
accounts,
existingSourceUrls,
onClose,
onProjectCreated,
}: {
readonly accounts: readonly GitProviderAccountOption[] | undefined;
readonly existingSourceUrls: readonly string[];
readonly onClose: () => void;
readonly onProjectCreated: (projectId: string) => void;
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const githubConnectionId = accounts?.find(
(account) => account.provider === "github" && account.status === "active"
)?.id as Id<"gitConnections"> | undefined;
useEffect(() => {
const dialog = dialogRef.current;
dialog?.showModal();
return () => dialog?.close();
}, []);
return createPortal(
<dialog
aria-labelledby="add-project-heading"
className="fixed inset-0 z-50 m-0 flex h-full w-full max-w-none items-center justify-center bg-[#20201d]/45 p-2 backdrop-blur-[2px] sm:p-6"
onCancel={(event) => {
event.preventDefault();
onClose();
}}
ref={dialogRef}
>
<section className="relative flex h-[calc(100svh-1rem)] w-full max-w-2xl flex-col overflow-hidden rounded bg-[#faf9f4] text-[#20201d] shadow-2xl sm:h-[min(46rem,calc(100svh-3rem))]">
<div className="flex shrink-0 items-center justify-between gap-5 border-b border-[#d7d3c7] px-5 py-4">
<h2
className="text-base font-semibold tracking-tight text-[#20201d]"
id="add-project-heading"
>
Add project
</h2>
<button
aria-label="Close add project"
className="grid size-8 shrink-0 place-items-center rounded text-[#747168] transition-colors hover:bg-[#f2f0e7] hover:text-[#20201d]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden p-5">
<ProviderChips accounts={accounts} />
<RepositorySelector
existingSourceUrls={existingSourceUrls}
githubConnectionId={githubConnectionId}
onProjectCreated={onProjectCreated}
/>
</div>
</section>
</dialog>,
document.body
);
};

View File

@@ -1,70 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useMutation, useQuery } from "convex/react";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const ContextEditor = ({
projectId,
}: {
readonly projectId: Id<"projects">;
}) => {
const instructions = useQuery(api.projects.getInstructions, {
projectId,
});
const updateInstructions = useMutation(api.projects.updateInstructions);
const [draft, setDraft] = useState<string>();
const [saving, setSaving] = useState(false);
const textareaId = `context-textarea-${projectId}`;
const currentDraft = draft ?? instructions ?? "";
const dirty = draft !== undefined && draft !== (instructions ?? "");
const save = async () => {
if (!dirty || saving) {
return;
}
setSaving(true);
try {
await updateInstructions({
instructions: draft ?? "",
projectId,
});
setDraft(undefined);
} finally {
setSaving(false);
}
};
return (
<div className="mt-3">
<label
className="text-xs font-medium text-[#20201d]"
htmlFor={textareaId}
>
Context
</label>
<textarea
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
id={textareaId}
onChange={(event) => setDraft(event.target.value)}
placeholder="Project-specific instructions for agents..."
value={currentDraft}
/>
{dirty ? (
<Button
className="mt-1 h-8 text-xs"
disabled={saving}
onClick={save}
size="sm"
type="button"
variant="outline"
>
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
{saving ? "Saving" : "Save context"}
</Button>
) : null}
</div>
);
};

View File

@@ -1,36 +0,0 @@
import { authClient } from "@code/auth/web";
import { LoaderCircle, GitBranch } from "lucide-react";
import { useState } from "react";
export const GitHubConnectButton = () => {
const [pending, setPending] = useState(false);
const linkGithub = async () => {
setPending(true);
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
});
} finally {
setPending(false);
}
};
return (
<button
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
onClick={linkGithub}
type="button"
>
<GitBranch className="size-5 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
</div>
{pending ? (
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
) : null}
</button>
);
};

View File

@@ -1,189 +0,0 @@
import { authClient } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import { LoaderCircle, RefreshCw, ShieldCheck, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/** Expanded scope set for repository + organization visibility. */
const GITHUB_EXPANDED_SCOPES = [
"repo",
"read:org",
"read:user",
"user:email",
] as const;
interface GithubConnectionSettingsProps {
readonly connectionId: string;
readonly externalUsername: string | undefined;
readonly grantedScopesJson: string | undefined;
}
export const GithubConnectionSettings = ({
connectionId,
externalUsername,
grantedScopesJson,
}: GithubConnectionSettingsProps) => {
const [open, setOpen] = useState(false);
const [reauthorizing, setReauthorizing] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
// Close on outside click.
useEffect(() => {
if (!open) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
// Parse granted scopes from stored JSON.
const grantedScopes: readonly string[] = (() => {
if (!grantedScopesJson) {
return [];
}
try {
const parsed = JSON.parse(grantedScopesJson) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((s): s is string => typeof s === "string");
}
return [];
} catch {
return [];
}
})();
const missingScopes = GITHUB_EXPANDED_SCOPES.filter(
(scope) => !grantedScopes.includes(scope)
);
const handleReauthorize = async () => {
setReauthorizing(true);
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
// Pass the full desired scope set so GitHub grants everything.
scopes: [...GITHUB_EXPANDED_SCOPES],
});
} catch {
setReauthorizing(false);
}
};
// Silence unused-vars: connectionId identifies which row is being managed.
void connectionId;
return (
<div className="relative" ref={popoverRef}>
<Button
aria-label="GitHub connection settings"
aria-haspopup="dialog"
aria-expanded={open}
className="h-8 w-8 gap-1 rounded border-[#d7d3c7] bg-[#fffefa] px-0 text-[#747168] hover:bg-[#f5f3eb]"
disabled={reauthorizing}
onClick={() => setOpen((value) => !value)}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="size-3.5" />
</Button>
{open ? (
<dialog
aria-modal="false"
className="absolute top-9 right-0 z-50 w-72 rounded-lg border border-[#d7d3c7] bg-[#fffefa] p-4 shadow-xl"
open
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-4 text-emerald-500" />
<h4 className="text-sm font-semibold text-[#20201d]">
GitHub Access
</h4>
</div>
<button
aria-label="Close settings"
className="text-[#858277] hover:text-[#20201d]"
onClick={() => setOpen(false)}
type="button"
>
<X className="size-3.5" />
</button>
</div>
{externalUsername ? (
<p className="mt-2 text-xs text-[#68665e]">
Connected as{" "}
<span className="font-medium text-[#20201d]">
{externalUsername}
</span>
</p>
) : null}
{grantedScopes.length > 0 ? (
<div className="mt-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-[#858277]">
Granted scopes
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{grantedScopes.map((scope) => (
<span
className="rounded bg-[#f5f3eb] px-1.5 py-0.5 text-[10px] font-medium text-[#68665e]"
key={scope}
>
{scope}
</span>
))}
</div>
</div>
) : (
<p className="mt-2 text-xs text-[#858277]">
Scope details will appear after reauthorization.
</p>
)}
<p className="mt-3 text-xs leading-5 text-[#68665e]">
Reauthorization refreshes GitHub scopes and re-syncs your accessible
repositories, including organization repos GitHub returns for your
account.
</p>
<p className="mt-2 text-[11px] leading-4 text-[#858277]">
Organization repository visibility depends on your GitHub org
membership and SSO authorization not all org repos may be
accessible via OAuth alone.
</p>
<Button
className="mt-3 w-full gap-1.5 rounded border-[#55564e] bg-[#20201d] text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
disabled={reauthorizing}
onClick={() => void handleReauthorize()}
size="sm"
type="button"
variant="outline"
>
{reauthorizing ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{reauthorizing ? "Redirecting…" : "Reauthorize access"}
</Button>
{missingScopes.length > 0 ? (
<p className="mt-2 text-[10px] leading-4 text-amber-600">
Not yet granted: {missingScopes.join(", ")}
</p>
) : null}
</dialog>
) : null}
</div>
);
};

View File

@@ -1,7 +0,0 @@
import { LoaderCircle } from "lucide-react";
export const LoadingState = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
</main>
);

View File

@@ -1,76 +0,0 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { ProjectView } from "@code/primitives/project";
import {
ArrowUpRight,
BookOpen,
ChevronDown,
FolderGit2,
GitBranch,
} from "lucide-react";
import { Link } from "react-router";
import { ContextEditor } from "./context-editor";
const dateFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
export const ProjectCard = ({ project }: { readonly project: ProjectView }) => {
const [source] = project.sources;
const contextDocuments = project.contextDocuments.filter(
(document) => document.content.trim().length > 0
);
const repositoryLabel =
source?.repositoryPath ?? source?.host ?? "Repository";
return (
<article className="group flex min-w-0 flex-col border border-[#d7d3c7] bg-[#fffefa] transition-colors hover:border-[#aaa69a]">
<Link
className="flex min-w-0 flex-1 flex-col p-5 outline-none focus-visible:ring-2 focus-visible:ring-[#55564e] focus-visible:ring-inset"
to={`/?project=${project.id}`}
>
<div className="flex items-start justify-between gap-4">
<span className="grid size-10 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
<FolderGit2 className="size-4" />
</span>
<ArrowUpRight className="mt-1 size-4 shrink-0 text-[#858277] transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[#20201d]" />
</div>
<div className="mt-5 min-w-0">
<h3 className="truncate text-base font-semibold tracking-tight text-[#20201d]">
{project.name}
</h3>
<p className="mt-1 truncate text-sm text-[#68665e]">
{repositoryLabel}
</p>
</div>
<div className="mt-6 flex flex-wrap gap-x-4 gap-y-2 border-t border-[#e4e0d4] pt-4 text-xs text-[#747168]">
{source?.defaultBranch ? (
<span className="inline-flex items-center gap-1.5">
<GitBranch className="size-3.5" />
{source.defaultBranch}
</span>
) : null}
<span className="inline-flex items-center gap-1.5">
<BookOpen className="size-3.5" />
{contextDocuments.length} context{" "}
{contextDocuments.length === 1 ? "source" : "sources"}
</span>
</div>
<p className="mt-3 text-xs text-[#858277]">
Updated {dateFormatter.format(project.updatedAt)}
</p>
</Link>
<details className="border-t border-[#e4e0d4]">
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-3 text-xs font-medium text-[#68665e] marker:content-none hover:text-[#20201d] [&::-webkit-details-marker]:hidden">
Project context
<ChevronDown className="size-3.5 transition-transform [[open]_&]:rotate-180" />
</summary>
<div className="border-t border-[#e4e0d4] px-5 pb-5">
<ContextEditor projectId={project.id as unknown as Id<"projects">} />
</div>
</details>
</article>
);
};

View File

@@ -1,65 +0,0 @@
import type { ProjectView } from "@code/primitives/project";
import { Search } from "lucide-react";
import { ProjectCard } from "./project-card";
export const ProjectsGrid = ({
onSearchChange,
projects,
search,
totalProjects,
}: {
readonly onSearchChange: (value: string) => void;
readonly projects: readonly ProjectView[];
readonly search: string;
readonly totalProjects: number;
}) => (
<section aria-labelledby="projects-heading" className="mt-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="flex items-baseline gap-2">
<h2
className="text-lg font-semibold tracking-tight text-[#20201d]"
id="projects-heading"
>
Your projects
</h2>
<span className="text-xs tabular-nums text-[#858277]">
{totalProjects}
</span>
</div>
<p className="mt-1 text-sm text-[#68665e]">
Choose a repository to continue its project conversation.
</p>
</div>
<label className="relative block w-full sm:w-64">
<span className="sr-only">Search projects</span>
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search projects"
type="search"
value={search}
/>
</label>
</div>
{projects.length > 0 ? (
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
) : (
<div className="mt-5 border border-dashed border-[#c9c5b9] bg-[#fffefa]/60 p-8 text-center">
<p className="text-sm font-medium text-[#20201d]">
No matching projects
</p>
<p className="mt-1 text-xs text-[#747168]">
Try a different project name or repository.
</p>
</div>
)}
</section>
);

View File

@@ -1,51 +0,0 @@
import { Button } from "@code/ui/components/button";
import { ArrowLeft, FolderGit2, Plus } from "lucide-react";
export const ProjectsHeader = ({
hasProjects,
onGoHome,
onOpenAddProject,
}: {
readonly hasProjects: boolean;
readonly onGoHome: () => void;
readonly onOpenAddProject: () => void;
}) => (
<header className="flex flex-col gap-5 border-b border-[#d7d3c7] pb-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
<FolderGit2 className="size-5" />
</span>
<div>
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
Zopu workspace
</p>
<h1 className="mt-0.5 text-2xl font-semibold tracking-tight text-[#20201d]">
Projects
</h1>
</div>
</div>
<div className="flex items-center gap-2">
{hasProjects ? (
<Button
className="border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
onClick={onGoHome}
size="lg"
type="button"
variant="outline"
>
<ArrowLeft className="size-3.5" />
Workspace
</Button>
) : null}
<Button
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
onClick={onOpenAddProject}
size="lg"
type="button"
>
<Plus className="size-4" />
Add project
</Button>
</div>
</header>
);

View File

@@ -1,166 +0,0 @@
import { authClient } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import {
CheckCircle2,
GitBranch,
LoaderCircle,
Server,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { GithubConnectionSettings } from "./github-connection-settings";
import { PuterConnectForm } from "./puter-connect-form";
export interface GitProviderAccountOption {
readonly externalUsername: string;
readonly grantedScopesJson?: string;
readonly id: string;
readonly provider: "github" | "gitea";
readonly serverUrl: string;
readonly status:
| "pending-auth"
| "active"
| "reauth-required"
| "revoked"
| "unavailable";
}
interface ProviderChipsProps {
readonly accounts: readonly GitProviderAccountOption[] | undefined;
}
interface ProviderChipProps {
readonly account: GitProviderAccountOption | undefined;
readonly icon: React.ElementType;
readonly label: string;
readonly loading: boolean;
readonly onClick: () => void;
}
const isHealthy = (status: GitProviderAccountOption["status"]) =>
status === "active";
const ProviderStatusIcon = ({ connected }: { readonly connected: boolean }) =>
connected ? (
<CheckCircle2 className="size-3 text-emerald-400" />
) : (
<XCircle className="size-3 text-amber-500" />
);
const ProviderChip = ({
account,
icon: Icon,
label,
loading,
onClick,
}: ProviderChipProps) => {
const connected = account !== undefined && isHealthy(account.status);
return (
<Button
aria-pressed={connected}
className={
connected
? "h-8 gap-1.5 rounded border-[#d7d3c7] bg-[#f5f3eb] px-3 text-[#20201d] hover:bg-[#f5f3eb]"
: "h-8 gap-1.5 rounded border-[#c9c5b9] bg-[#fffefa] px-3 text-[#68665e] hover:bg-[#f5f3eb]"
}
disabled={loading || connected}
onClick={onClick}
size="sm"
type="button"
variant="outline"
>
{loading ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Icon className="size-3.5 text-[#747168]" />
)}
<span className="text-xs font-medium">
{connected ? account.externalUsername : `Connect ${label}`}
</span>
{account === undefined ? null : (
<ProviderStatusIcon connected={connected} />
)}
</Button>
);
};
export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
const [pendingProvider, setPendingProvider] = useState<
"github" | "gitea" | undefined
>();
const [showPuterForm, setShowPuterForm] = useState(false);
const githubAccount = accounts?.find(
(account) => account.provider === "github"
);
const puterAccount = accounts?.find(
(account) => account.provider === "gitea"
);
const linkGithub = async () => {
if (pendingProvider) {
return;
}
setPendingProvider("github");
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
});
} finally {
setPendingProvider(undefined);
}
};
const handlePuterChipClick = () => {
if (puterAccount && isHealthy(puterAccount.status)) {
return;
}
setShowPuterForm((value) => !value);
};
const handlePuterConnected = () => {
setShowPuterForm(false);
};
const accountsLoading = accounts === undefined;
const githubConnected =
githubAccount !== undefined && isHealthy(githubAccount.status);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<ProviderChip
account={githubAccount}
icon={GitBranch}
label="GitHub"
loading={accountsLoading || pendingProvider === "github"}
onClick={linkGithub}
/>
{githubConnected ? (
<GithubConnectionSettings
connectionId={githubAccount.id}
externalUsername={githubAccount.externalUsername}
grantedScopesJson={githubAccount.grantedScopesJson}
/>
) : null}
</div>
<ProviderChip
account={puterAccount}
icon={Server}
label="Puter Git"
loading={accountsLoading || pendingProvider === "gitea"}
onClick={handlePuterChipClick}
/>
</div>
{showPuterForm ? (
<div className="rounded border border-[#d7d3c7] bg-[#fffefa] p-3 shadow-sm">
<PuterConnectForm onConnected={handlePuterConnected} />
</div>
) : null}
</div>
);
};

View File

@@ -1,46 +0,0 @@
import { Server } from "lucide-react";
import { useState } from "react";
import { GitHubConnectButton } from "./github-connect-button";
import { PuterConnectForm } from "./puter-connect-form";
export const ProviderSection = () => {
const [showPuterForm, setShowPuterForm] = useState(false);
return (
<section aria-labelledby="provider-connections-heading">
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
Need a repository?
</p>
<h3
className="mt-1 text-base font-semibold text-[#20201d]"
id="provider-connections-heading"
>
Connect a Git provider
</h3>
<p className="mt-1 text-xs leading-5 text-[#68665e]">
Link GitHub for OAuth access or add a Puter Git personal access token.
</p>
<div className="mt-4 space-y-2">
<GitHubConnectButton />
<button
aria-expanded={showPuterForm}
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
onClick={() => setShowPuterForm((value) => !value)}
type="button"
>
<Server className="size-4 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
<p className="text-xs text-[#747168]">
Connect with a personal access token
</p>
</div>
</button>
</div>
{showPuterForm ? (
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
) : null}
</section>
);
};

View File

@@ -1,80 +0,0 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useAction } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
interface ConnectGiteaResult {
connectionId: Id<"gitConnections">;
}
const connectGiteaRef = makeFunctionReference<
"action",
{ token: string; username?: string },
ConnectGiteaResult
>("gitConnections:connectGitea");
export const PuterConnectForm = ({
onConnected,
}: {
readonly onConnected: () => void;
}) => {
const connectGitea = useAction(connectGiteaRef);
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!token.trim() || pending) {
return;
}
setPending(true);
setError(undefined);
try {
await connectGitea({
token: token.trim(),
username: username.trim() || undefined,
});
setToken("");
setUsername("");
onConnected();
} catch (caughtError) {
setError(
caughtError instanceof Error ? caughtError.message : String(caughtError)
);
} finally {
setPending(false);
}
};
return (
<form className="mt-4 space-y-3" onSubmit={submit}>
<p className="text-xs text-[#68665e]">
Connect your Puter Git personal access token. The Puter Git instance is
at <code className="text-[#20201d]">git.openputer.com</code>.
</p>
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => setUsername(event.target.value)}
placeholder="Puter username (optional)"
value={username}
/>
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => setToken(event.target.value)}
placeholder="Personal access token"
required
type="password"
value={token}
/>
{error ? <p className="text-xs text-red-700">{error}</p> : null}
<Button className="h-10 w-full" disabled={pending} type="submit">
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
{pending ? "Connecting" : "Connect Puter Git"}
</Button>
</form>
);
};

View File

@@ -1,346 +0,0 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import {
ArrowUpRight,
LoaderCircle,
FolderGit2,
GitBranch,
Globe2,
Lock,
Search,
Server,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { useGithubRepoSearch } from "@/hooks/use-github-repo-search";
import type { GithubRepositorySearchResult } from "@/hooks/use-github-repo-search";
import { useRepositoryPicker } from "@/hooks/use-repository-picker";
import type { GitRepositoryOption } from "@/hooks/use-repository-picker";
const listRepositoriesRef = makeFunctionReference<
"query",
Record<string, never>,
readonly GitRepositoryOption[]
>("gitProvisioning:listRepositories");
interface CreateProjectResult {
projectId: Id<"projects">;
}
const createProjectFromRepositoryRef = makeFunctionReference<
"mutation",
{ gitRepositoryId: Id<"gitRepositories">; name: string },
CreateProjectResult
>("gitProvisioning:createProjectFromRepository");
const providerLabels = {
all: "All",
gitea: "Puter Git",
github: "GitHub",
} as const;
export const RepositorySelector = ({
existingSourceUrls,
githubConnectionId,
onProjectCreated,
}: {
readonly existingSourceUrls: readonly string[];
readonly githubConnectionId: Id<"gitConnections"> | undefined;
readonly onProjectCreated: (projectId: string) => void;
}) => {
const repositories = useQuery(listRepositoriesRef, {});
const createProject = useMutation(createProjectFromRepositoryRef);
const [error, setError] = useState<string>();
const [pending, setPending] = useState<string>();
const {
availableRepositories,
privacyFilter,
providerFilter,
search,
setPrivacyFilter,
setProviderFilter,
setSearch,
} = useRepositoryPicker({
existingSourceUrls,
repositories,
});
// Live GitHub search: active when GitHub provider is selectable (filter is
// "all" or "github") and a GitHub connection exists. The search action uses
// the server-side encrypted credential — the token never reaches the browser.
const githubSearchEnabled =
githubConnectionId !== undefined &&
(providerFilter === "all" || providerFilter === "github");
const githubSearch = useGithubRepoSearch({
connectionId: githubConnectionId,
enabled: githubSearchEnabled,
query: search,
});
// Merge live search results with the synced snapshot. Live results take
// priority by webUrl to avoid duplicate rows.
const liveResults = useMemo<readonly GithubRepositorySearchResult[]>(() => {
if (githubSearch.kind !== "results") {
return [];
}
return githubSearch.results;
}, [githubSearch]);
const displayRepositories = useMemo<readonly GitRepositoryOption[]>(() => {
const base = availableRepositories ?? [];
if (liveResults.length === 0) {
return base;
}
const existingUrls = new Set(base.map((repo) => repo.webUrl));
const merged: GitRepositoryOption[] = [...base];
for (const result of liveResults) {
if (!result.gitRepositoryId || existingUrls.has(result.webUrl)) {
continue;
}
existingUrls.add(result.webUrl);
merged.push({
cloneUrl: result.cloneUrl,
defaultBranch: result.defaultBranch,
fullName: result.fullName,
id: result.gitRepositoryId,
name: result.name,
owner: result.owner,
privacy: result.privacy,
provider: result.provider,
webUrl: result.webUrl,
});
}
return merged.toSorted((left, right) =>
left.fullName.localeCompare(right.fullName)
);
}, [availableRepositories, liveResults]);
const create = async (repository: GitRepositoryOption) => {
if (pending) {
return;
}
setError(undefined);
setPending(repository.id);
try {
const result = await createProject({
gitRepositoryId: repository.id as Id<"gitRepositories">,
name: repository.name,
});
onProjectCreated(String(result.projectId));
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not create this project"
);
} finally {
setPending(undefined);
}
};
if (repositories === undefined || availableRepositories === undefined) {
return (
<div className="grid min-h-48 flex-1 place-items-center border border-[#d7d3c7] bg-[#fffefa]">
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
</div>
);
}
const isFiltered =
Boolean(search) || providerFilter !== "all" || privacyFilter !== "all";
return (
<section
aria-labelledby="repository-picker-heading"
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3
className="text-base font-semibold text-[#20201d]"
id="repository-picker-heading"
>
Imports
</h3>
<p className="mt-0.5 text-xs text-[#747168]">
Repositories already added as projects are hidden.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Link
className="inline-flex items-center gap-1 text-xs font-medium text-[#20201d] underline decoration-[#858277] underline-offset-4 hover:decoration-[#20201d]"
to="/?import=public"
>
Import public Git URL
<ArrowUpRight className="size-3" />
</Link>
<p className="text-xs tabular-nums text-[#858277]">
{availableRepositories.length} available
</p>
</div>
</div>
<div className="mt-4 grid shrink-0 gap-3 sm:flex sm:flex-wrap sm:items-center sm:justify-between">
<label className="relative min-w-0 sm:max-w-[16rem]">
<span className="sr-only">Search repositories</span>
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
<input
className="h-9 w-full rounded border border-[#d7d3c7] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none ring-[#d7d3c7] placeholder:text-[#858277] focus:border-[#858277] focus:ring-1"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search repositories"
type="search"
value={search}
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<fieldset className="flex flex-wrap gap-1">
<legend className="sr-only">Repository provider</legend>
{(
Object.keys(providerLabels) as (keyof typeof providerLabels)[]
).map((provider) => (
<Button
aria-pressed={providerFilter === provider}
className={
providerFilter === provider
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
}
key={provider}
onClick={() => setProviderFilter(provider)}
type="button"
variant="outline"
>
{providerLabels[provider]}
</Button>
))}
</fieldset>
<fieldset className="flex flex-wrap gap-1">
<legend className="sr-only">Repository visibility</legend>
{(["all", "private", "public"] as const).map((privacy) => (
<Button
aria-pressed={privacyFilter === privacy}
className={
privacyFilter === privacy
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
}
key={privacy}
onClick={() => setPrivacyFilter(privacy)}
type="button"
variant="outline"
>
{privacy === "all" ? "All" : privacy}
</Button>
))}
</fieldset>
</div>
</div>
{error ? (
<p className="mt-3 border border-red-300 bg-red-50 p-2.5 text-xs leading-5 text-red-700">
{error}
</p>
) : null}
{githubSearch.kind === "loading" ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-[#747168]">
<LoaderCircle className="size-3 animate-spin" />
Searching GitHub
</p>
) : null}
{githubSearch.kind === "error" ? (
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
{githubSearch.message}
</p>
) : null}
{githubSearch.kind === "reauth" ? (
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
GitHub access needs reauthorization. Use the settings control on the
GitHub chip to reconnect.
</p>
) : null}
{displayRepositories.length > 0 ? (
<div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]">
{displayRepositories.map((repository) => {
const ProviderIcon =
repository.provider === "github" ? GitBranch : Server;
const VisibilityIcon =
repository.privacy === "private" ? Lock : Globe2;
return (
<div
className="flex items-center justify-between gap-3 p-3"
key={repository.id}
>
<div className="flex min-w-0 items-start gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded border border-[#d7d3c7] bg-[#f5f3eb] text-[#68665e]">
<ProviderIcon className="size-3.5" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[#20201d]">
{repository.fullName}
</p>
<div className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] text-[#747168]">
<span>{repository.defaultBranch}</span>
<span className="inline-flex items-center gap-1">
<VisibilityIcon className="size-3" />
{repository.privacy}
</span>
</div>
</div>
</div>
<Button
className="h-7 shrink-0 rounded border-[#55564e] bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
disabled={pending !== undefined}
onClick={() => void create(repository)}
type="button"
variant="outline"
>
{pending === repository.id ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<FolderGit2 className="size-3" />
)}
{pending === repository.id ? "Creating" : "Add"}
</Button>
</div>
);
})}
</div>
) : (
<div className="mt-3 rounded border border-dashed border-[#c9c5b9] bg-[#fffefa] p-6 text-center">
<FolderGit2 className="mx-auto size-5 text-[#858277]" />
<p className="mt-3 text-sm font-medium text-[#20201d]">
{isFiltered
? "No repositories match these filters"
: "No repositories available"}
</p>
<p className="mt-1 text-xs leading-5 text-[#68665e]">
{isFiltered
? "Change your search or filters to see other repositories."
: "Connect a Git provider or import a public repository to continue."}
</p>
{isFiltered ? (
<Button
className="mt-4 border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
onClick={() => {
setPrivacyFilter("all");
setProviderFilter("all");
setSearch("");
}}
size="sm"
type="button"
variant="outline"
>
Clear filters
</Button>
) : null}
</div>
)}
</section>
);
};

View File

@@ -1,11 +1,11 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import * as React from "react";
export const ThemeProvider = ({
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) => (
<NextThemesProvider {...props}>{children}</NextThemesProvider>
);
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
export { useTheme } from "next-themes";

View File

@@ -79,7 +79,7 @@ export const ConversationComposer = ({
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base outline-none focus:border-[#55564e]"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {

View File

@@ -1,8 +1,11 @@
import { FolderGit2, Menu } from "lucide-react";
import { Link } from "react-router";
import { Button } from "@code/ui/components/button";
import { Menu, Settings } from "lucide-react";
import { useState } from "react";
import type { WorkspaceState } from "@/lib/workspace/types";
import { ProjectSettingsPanel } from "./project-settings-panel";
export const ProjectHeader = ({
onOpenDrawer,
workspace,
@@ -10,10 +13,11 @@ export const ProjectHeader = ({
readonly onOpenDrawer: () => void;
readonly workspace: WorkspaceState;
}) => {
const [settingsOpen, setSettingsOpen] = useState(false);
const works = workspace.works ?? [];
return (
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
@@ -31,13 +35,15 @@ export const ProjectHeader = ({
Conversation to proposed Work
</p>
</div>
<Link
aria-label="Projects"
className="mr-2 grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
to="/projects"
<Button
aria-label="Project settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
>
<FolderGit2 className="size-4" />
</Link>
<Settings className="size-4" />
</Button>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={onOpenDrawer}
@@ -46,6 +52,12 @@ export const ProjectHeader = ({
<Menu className="size-4" /> Work{" "}
{workspace.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<ProjectSettingsPanel
onClose={() => setSettingsOpen(false)}
workspace={workspace}
/>
) : null}
</header>
);
};

View File

@@ -0,0 +1,100 @@
import { Button } from "@code/ui/components/button";
import { AlertTriangle, X } from "lucide-react";
import { useState } from "react";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectSettingsPanel = ({
onClose,
workspace,
}: {
readonly onClose: () => void;
readonly workspace: WorkspaceState;
}) => {
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
const [username, setUsername] = useState("");
const [token, setToken] = useState("");
const handleClearOperationError = () => workspace.clearOperationError();
return (
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Project Git</h2>
<button aria-label="Close settings" onClick={onClose} type="button">
<X className="size-4" />
</button>
</div>
<p className="mt-1 text-xs text-[#747168]">
{workspace.projectGitConnection
? `${workspace.projectGitConnection.provider} · ${workspace.projectGitConnection.serverUrl}`
: "No Git credentials attached"}
</p>
{workspace.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{workspace.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={handleClearOperationError}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void workspace.authorizeGithub()}
>
Authorize GitHub
</Button>
<Button size="sm" onClick={() => void workspace.connectLinkedGithub()}>
Use GitHub
</Button>
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setServerUrl(event.target.value)}
value={serverUrl}
/>
<input
aria-label="Gitea username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setUsername(event.target.value)}
placeholder="Username (optional)"
value={username}
/>
<input
aria-label="Gitea access token"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setToken(event.target.value)}
placeholder="Personal access token"
type="password"
value={token}
/>
<Button
className="w-full"
disabled={!token.trim()}
size="sm"
onClick={() => {
void workspace.connectGitea({
serverUrl,
token,
username: username || undefined,
});
setToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
);
};

View File

@@ -1,6 +1,7 @@
import { Button } from "@code/ui/components/button";
import {
AlertTriangle,
Check,
ChevronRight,
FileCode2,
Hammer,
@@ -31,13 +32,16 @@ const starterDefinition = (work: WorkRecord) => ({
outOfScope: ["Unrelated product changes"],
problem: work.objective,
questions: [],
requiredArtifacts: ["Implementation diff and terminal outcome"],
requiredArtifacts: ["Simulation activity and terminal outcome"],
risk: "medium",
});
const starterDesign = (work: WorkRecord) => ({
architectureSummary: "Implement the requested outcome in focused changes.",
callFlowDelta: ["Work -> Run -> Attempt -> AgentOS -> candidate revision"],
architectureSummary:
"Validate the approved Definition, then exercise one deterministic fake slice.",
callFlowDelta: [
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
@@ -45,30 +49,30 @@ const starterDesign = (work: WorkRecord) => ({
files: [],
modules: [],
risks: [],
summary: "Focused implementation",
summary: "Compact vertical-slice simulation",
},
invariants: [
"A successful AgentOS response alone never marks Work complete",
"Simulation never claims implementation",
"Every Attempt reaches a terminal classification",
],
keyInterfaces: ["WorkExecution", "AttemptResult"],
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
id: "implementation",
id: "deterministic-simulation",
objective: work.objective,
observableBehavior: "Implementation changes are visible in the diff",
observableBehavior: "A terminal fake Run is visible",
reviewRequired: false,
title: "Implementation",
title: "Deterministic simulation",
verification: ["Run completes with a terminal classification"],
},
],
tradeoffs: [],
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
});
// oxlint-disable-next-line complexity -- this card coordinates planning and run evidence.
// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
export const WorkCard = ({
onSourceSelect,
work,
@@ -91,7 +95,7 @@ export const WorkCard = ({
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
{work.status === "proposed" ? "Proposed Work" : "Work"}
Proposed Work
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
@@ -145,8 +149,7 @@ export const WorkCard = ({
Outcome
</p>
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
<p className="mt-2 text-[#747168]">Status: {work.status}</p>
<p className="mt-1 text-[#747168]">
<p className="mt-2 text-[#747168]">
Risk: {work.definition?.risk ?? "not defined"}
</p>
{openQuestions > 0 ? (
@@ -177,6 +180,20 @@ export const WorkCard = ({
<Hammer className="size-3.5" /> Save definition
</Button>
) : null}
{work.status === "awaiting-definition-approval" &&
work.definitionVersion ? (
<Button
size="sm"
onClick={() =>
void workspace.approveDefinition(
work._id,
work.definitionVersion as number
)
}
>
<Check className="size-3.5" /> Approve
</Button>
) : null}
</div>
</section>
<section>
@@ -203,6 +220,22 @@ export const WorkCard = ({
<Hammer className="size-3.5" /> Save design
</Button>
) : null}
{work.status === "awaiting-design-approval" &&
work.definitionApprovalVersion &&
work.designVersion ? (
<Button
size="sm"
onClick={() =>
void workspace.approveDesign(
work._id,
work.definitionApprovalVersion as number,
work.designVersion as number
)
}
>
<Check className="size-3.5" /> Approve design
</Button>
) : null}
</div>
</section>
<section>
@@ -228,7 +261,10 @@ export const WorkCard = ({
{latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
Run {latestRun.status}:{" "}
{latestRun.executionKind === "real"
? "AgentOS"
: "Simulation"}{" "}
run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
@@ -307,29 +343,45 @@ export const WorkCard = ({
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<Button
size="sm"
disabled={!workspace.projectGitConnection}
onClick={() => void workspace.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
<>
<Button
size="sm"
disabled={!workspace.projectGitConnection}
onClick={() => void workspace.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.startSimulation(work._id, "success")
}
>
<Play className="size-3.5" /> Simulate
</Button>
</>
) : null}
{latestRun?.status === "running" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.cancelExecution(latestRun._id)}
onClick={() =>
void (latestRun.executionKind === "real"
? workspace.cancelExecution(latestRun._id)
: workspace.cancelSimulation(latestRun._id))
}
>
<X className="size-3.5" /> Cancel
</Button>
) : null}
{latestRun?.status === "terminal" &&
{latestRun?.executionKind !== "real" &&
latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.retryExecution(latestRun._id)}
onClick={() => void workspace.retrySimulation(latestRun._id)}
>
<RotateCcw className="size-3.5" /> Retry
</Button>

View File

@@ -1,25 +0,0 @@
import type { ProjectView } from "@code/primitives/project";
import { useMemo } from "react";
export const useFilteredProjects = ({
projects,
search,
}: {
readonly projects: readonly ProjectView[];
readonly search: string;
}) =>
useMemo(() => {
const searchTerm = search.trim().toLowerCase();
if (!searchTerm) {
return projects;
}
return projects.filter((project) => {
const [source] = project.sources;
return (
project.name.toLowerCase().includes(searchTerm) ||
source?.repositoryPath.toLowerCase().includes(searchTerm) ||
source?.host.toLowerCase().includes(searchTerm)
);
});
}, [projects, search]);

View File

@@ -1,102 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react";
export type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
export type GithubSearchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "results"; results: readonly GithubRepositorySearchResult[] }
| { kind: "error"; message: string }
| { kind: "reauth" };
const MIN_QUERY_LENGTH = 2;
const DEBOUNCE_MS = 350;
/**
* Debounced live GitHub repository search. Calls the
* `gitConnections:searchGithubRepositories` action with the user's stored
* encrypted credential (the token never reaches the browser). Returns a
* stale-safe, cancellable search state.
*
* Only searches when the GitHub provider filter is active (or "all") and a
* query is at least `MIN_QUERY_LENGTH` characters.
*/
export const useGithubRepoSearch = ({
connectionId,
enabled,
query,
}: {
readonly connectionId: Id<"gitConnections"> | undefined;
readonly enabled: boolean;
readonly query: string;
}): GithubSearchState => {
const searchAction = useAction(api.gitConnections.searchGithubRepositories);
const [state, setState] = useState<GithubSearchState>({ kind: "idle" });
// Track the latest request so stale responses are discarded.
const requestIdRef = useRef(0);
const trimmed = query.trim();
const shouldBeSearching =
enabled && connectionId !== undefined && trimmed.length >= MIN_QUERY_LENGTH;
useEffect(() => {
if (!shouldBeSearching || !connectionId) {
return;
}
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
// eslint-disable-next-line react-compiler/react-compiler -- loading state must be set synchronously before the debounced fetch
setState({ kind: "loading" });
const timer = setTimeout(() => {
void (async () => {
try {
const results = await searchAction({
connectionId,
query: trimmed,
});
// Discard if a newer request superseded this one.
if (requestIdRef.current !== currentRequestId) {
return;
}
setState({ kind: "results", results });
} catch (caughtError) {
if (requestIdRef.current !== currentRequestId) {
return;
}
const message =
caughtError instanceof Error
? caughtError.message
: "GitHub search failed";
if (
message.toLowerCase().includes("reauth") ||
message.toLowerCase().includes("rejected") ||
message.toLowerCase().includes("reconnect")
) {
setState({ kind: "reauth" });
} else {
setState({ kind: "error", message });
}
}
})();
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
};
}, [connectionId, shouldBeSearching, searchAction, trimmed]);
// Reset to idle when search is no longer active.
useEffect(() => {
if (!shouldBeSearching && state.kind !== "idle") {
// eslint-disable-next-line react-compiler/react-compiler -- reset state when search is no longer active
setState({ kind: "idle" });
}
}, [shouldBeSearching, state.kind]);
return state;
};

View File

@@ -1,22 +0,0 @@
import { useCallback, useState } from "react";
export const useProjectsPageState = ({
hasProjects,
}: {
readonly hasProjects: boolean | undefined;
}) => {
const [addProjectOpen, setAddProjectOpen] = useState<boolean>();
const [projectSearch, setProjectSearch] = useState("");
const isAddProjectOpen = addProjectOpen ?? hasProjects === false;
const closeAddProject = useCallback(() => setAddProjectOpen(false), []);
const openAddProject = useCallback(() => setAddProjectOpen(true), []);
return {
closeAddProject,
isAddProjectOpen,
openAddProject,
projectSearch,
setProjectSearch,
};
};

View File

@@ -1,64 +0,0 @@
import { useMemo, useState } from "react";
export interface GitRepositoryOption {
readonly cloneUrl: string;
readonly defaultBranch: string;
readonly fullName: string;
readonly id: string;
readonly name: string;
readonly owner: string;
readonly privacy: "public" | "private";
readonly provider: "github" | "gitea";
readonly webUrl: string;
}
type ProviderFilter = "all" | GitRepositoryOption["provider"];
type PrivacyFilter = "all" | GitRepositoryOption["privacy"];
export const useRepositoryPicker = ({
existingSourceUrls,
repositories,
}: {
readonly existingSourceUrls: readonly string[];
readonly repositories: readonly GitRepositoryOption[] | undefined;
}) => {
const [privacyFilter, setPrivacyFilter] = useState<PrivacyFilter>("all");
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
const [search, setSearch] = useState("");
const availableRepositories = useMemo(() => {
if (!repositories) {
return;
}
const existingUrls = new Set(existingSourceUrls);
const searchTerm = search.trim().toLowerCase();
return repositories
.filter((repository) => !existingUrls.has(repository.webUrl))
.filter(
(repository) =>
providerFilter === "all" || repository.provider === providerFilter
)
.filter(
(repository) =>
privacyFilter === "all" || repository.privacy === privacyFilter
)
.filter(
(repository) =>
!searchTerm ||
repository.fullName.toLowerCase().includes(searchTerm) ||
repository.owner.toLowerCase().includes(searchTerm)
)
.toSorted((left, right) => left.fullName.localeCompare(right.fullName));
}, [existingSourceUrls, privacyFilter, providerFilter, repositories, search]);
return {
availableRepositories,
privacyFilter,
providerFilter,
search,
setPrivacyFilter,
setProviderFilter,
setSearch,
};
};

View File

@@ -4,7 +4,6 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
@@ -13,6 +12,13 @@ import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
type ExecutionScenario =
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled";
const workListRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
@@ -28,26 +34,46 @@ const saveDefinitionRef = makeFunctionReference<
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDefinitionProposal");
const approveDefinitionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; version: number },
unknown
>("workPlanning:approveDefinition");
const saveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDesignProposal");
const approveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; definitionVersion: number; designVersion: number },
unknown
>("workPlanning:approveDesign");
const startSimulationRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
unknown
>("workExecution:startSimulatedExecution");
const cancelSimulationRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:cancelSimulatedExecution");
const retrySimulationRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:retrySimulatedExecution");
const startExecutionRef = makeFunctionReference<
"mutation",
{ sliceId?: string; workId: Id<"works"> },
{ workId: Id<"works">; sliceId?: string },
unknown
>("workExecution:start");
>("workExecutionWorkflow:startExecution");
const cancelExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:cancel");
const retryExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:retry");
>("workExecutionWorkflow:cancelExecution");
const listGitConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
@@ -70,7 +96,7 @@ const projectGitConnectionRef = makeFunctionReference<
>("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference<
"action",
{ token: string; username?: string },
{ serverUrl: string; token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference<
@@ -92,7 +118,6 @@ export const useProjectWorkspace = (): WorkspaceState => {
);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useOrganizationChatAgent(organization);
const [searchParams] = useSearchParams();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
@@ -100,17 +125,11 @@ export const useProjectWorkspace = (): WorkspaceState => {
const [error, setError] = useState<Error>();
const [operationError, setOperationError] = useState<Error>();
const projectParam = searchParams.get("project");
const paramProject = projectParam
? (projectParam as unknown as Id<"projects">)
: null;
const selectedProjectStillExists = projects?.some(
(project) =>
project.id === (selectedProjectId as unknown as string) ||
project.id === (paramProject as unknown as string)
(project) => project.id === (selectedProjectId as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? (selectedProjectId ?? paramProject)
? selectedProjectId
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
workListRef,
@@ -126,10 +145,14 @@ export const useProjectWorkspace = (): WorkspaceState => {
);
const requestDefinitionMutation = useMutation(requestDefinitionRef);
const saveDefinitionMutation = useMutation(saveDefinitionRef);
const approveDefinitionMutation = useMutation(approveDefinitionRef);
const saveDesignMutation = useMutation(saveDesignRef);
const approveDesignMutation = useMutation(approveDesignRef);
const startSimulationMutation = useMutation(startSimulationRef);
const cancelSimulationMutation = useMutation(cancelSimulationRef);
const retrySimulationMutation = useMutation(retrySimulationRef);
const startExecutionMutation = useMutation(startExecutionRef);
const cancelExecutionMutation = useMutation(cancelExecutionRef);
const retryExecutionMutation = useMutation(retryExecutionRef);
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
const connectGiteaAction = useAction(connectGiteaRef);
const connectGithubAction = useAction(connectGithubRef);
@@ -179,7 +202,11 @@ export const useProjectWorkspace = (): WorkspaceState => {
});
};
const connectGitea = (input: { token: string; username?: string }) =>
const connectGitea = (input: {
serverUrl: string;
token: string;
username?: string;
}) =>
runOperation(async () => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
@@ -193,13 +220,22 @@ export const useProjectWorkspace = (): WorkspaceState => {
return {
agent,
approveDefinition: (workId: Id<"works">, version: number) =>
approveDefinitionMutation({ version, workId }),
approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () =>
authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
authClient.signIn.social({
callbackURL: window.location.href,
provider: "github",
}),
cancelExecution: (runId: Id<"workRuns">) =>
runOperation(() => cancelExecutionMutation({ runId })),
cancelSimulation: (runId: Id<"workRuns">) =>
runOperation(() => cancelSimulationMutation({ runId })),
clearOperationError: () => setOperationError(undefined),
connectGitea,
connectLinkedGithub,
@@ -213,8 +249,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
repository,
requestDefinition: (workId: Id<"works">) =>
requestDefinitionMutation({ workId }),
retryExecution: (runId: Id<"workRuns">) =>
runOperation(() => retryExecutionMutation({ runId })),
retrySimulation: (runId: Id<"workRuns">) =>
runOperation(() => retrySimulationMutation({ runId })),
saveDefinition: (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
saveDesign: (workId: Id<"works">, payload: unknown) =>
@@ -227,6 +263,14 @@ export const useProjectWorkspace = (): WorkspaceState => {
setRepository,
startExecution: (workId: Id<"works">, sliceId?: string) =>
runOperation(() => startExecutionMutation({ sliceId, workId })),
startSimulation: (
workId: Id<"works">,
scenario: ExecutionScenario,
sliceId?: string
) =>
runOperation(() =>
startSimulationMutation({ scenario, sliceId, workId })
),
works,
};
};

View File

@@ -56,39 +56,4 @@ describe("projectConversation", () => {
{ state: "done", text: "Captured the request.", type: "text" },
]);
});
test("clears a historical failure after a newer turn succeeds", () => {
const state = projectConversation([
row({
error: "Old admission failure",
messageId: "assistant-failed",
role: "assistant",
status: "failed",
}),
row({
messageId: "assistant-completed",
rawText: "Recovered",
role: "assistant",
status: "completed",
}),
]);
expect(state.failedError).toBeUndefined();
});
test("clears historical pending state after a newer turn completes", () => {
const state = projectConversation([
row({
messageId: "assistant-stale",
role: "assistant",
status: "running",
}),
row({
messageId: "assistant-completed",
rawText: "Recovered",
role: "assistant",
status: "completed",
}),
]);
expect(state.pending).toBe(false);
});
});

View File

@@ -20,18 +20,7 @@ export interface ConversationRow {
| "running";
}
const latestAssistant = (
rows: readonly ConversationRow[]
): ConversationRow | undefined =>
rows.findLast((row) => row.role === "assistant");
const latestTerminalError = (row: ConversationRow | undefined) =>
row?.status === "failed"
? (row.error ?? "Conversation turn failed")
: undefined;
export const projectConversation = (rows: readonly ConversationRow[]) => {
const activeAssistant = latestAssistant(rows);
const streaming = rows.some(
(row) =>
row.role === "assistant" &&
@@ -39,7 +28,9 @@ export const projectConversation = (rows: readonly ConversationRow[]) => {
row.rawText.length > 0
);
return {
failedError: latestTerminalError(activeAssistant),
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
messages: rows
.filter(
(row) =>
@@ -74,10 +65,13 @@ export const projectConversation = (rows: readonly ConversationRow[]) => {
],
role: row.role,
})),
pending:
activeAssistant?.status === "queued" ||
activeAssistant?.status === "dispatching" ||
activeAssistant?.status === "running",
pending: rows.some(
(row) =>
row.role === "assistant" &&
(row.status === "queued" ||
row.status === "dispatching" ||
row.status === "running")
),
streaming,
};
};

View File

@@ -9,6 +9,7 @@ export interface WorkRecord {
readonly objective: string;
readonly status: string;
readonly definitionVersion?: number;
readonly definitionApprovalVersion?: number;
readonly designVersion?: number;
readonly signals: readonly {
readonly sources: readonly { messageId: string; rawText: string }[];
@@ -33,6 +34,7 @@ export interface WorkRun {
readonly attemptEvents?: readonly WorkAttemptEvent[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
readonly _id: Id<"workRuns">;
readonly status: string;
readonly terminalClassification?: string;
@@ -79,10 +81,21 @@ export interface WorkspaceState {
| "streaming"
| "submitted";
};
readonly approveDefinition: (
workId: Id<"works">,
version: number
) => Promise<unknown>;
readonly approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => Promise<unknown>;
readonly authorizeGithub: () => Promise<unknown>;
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly clearOperationError: () => void;
readonly connectGitea: (input: {
readonly serverUrl: string;
readonly token: string;
readonly username?: string;
}) => Promise<void>;
@@ -96,7 +109,7 @@ export interface WorkspaceState {
readonly projects: readonly ProjectListItem[] | undefined;
readonly repository: string;
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
readonly retryExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly saveDefinition: (
workId: Id<"works">,
payload: unknown
@@ -112,6 +125,16 @@ export interface WorkspaceState {
workId: Id<"works">,
sliceId?: string
) => Promise<unknown>;
readonly startSimulation: (
workId: Id<"works">,
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled",
sliceId?: string
) => Promise<unknown>;
readonly works: readonly WorkRecord[] | undefined;
}

View File

@@ -6,8 +6,5 @@ export default [
route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
index("./routes/app/workspace/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
] satisfies RouteConfig;

View File

@@ -1,9 +1,5 @@
import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router";
import { Outlet } from "react-router";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { requireAuthToken } from "@/lib/auth.server";
import type { Route } from "./+types/layout";
@@ -12,24 +8,5 @@ export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request);
export default function AppLayout() {
const organization = usePersonalOrganization();
const navigate = useNavigate();
const projects = useQuery(
api.projects.list,
organization.organizationId ? {} : "skip"
);
useEffect(() => {
if (
projects !== undefined &&
projects.length === 0 &&
window.location.pathname === "/" &&
!window.location.search.includes("resume=") &&
!window.location.search.includes("project=")
) {
void navigate("/projects", { replace: true });
}
}, [projects, navigate]);
return <Outlet />;
}

View File

@@ -1,125 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { AddProjectPanel } from "@/components/projects/add-project-panel";
import { LoadingState } from "@/components/projects/loading-state";
import { ProjectsGrid } from "@/components/projects/projects-grid";
import { ProjectsHeader } from "@/components/projects/projects-header";
import type { GitProviderAccountOption } from "@/components/projects/provider-chips";
import { useFilteredProjects } from "@/hooks/use-filtered-projects";
import { useProjectsPageState } from "@/hooks/use-projects-page-state";
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
export default function ProjectsRoute() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const projects = useQuery(api.projects.list, {});
const providerAccounts = useQuery(
api.gitProvisioning.listProviderAccounts,
{}
) as readonly GitProviderAccountOption[] | undefined;
const connectGithubAction = useAction(connectGithubRef);
const [resumeError, setResumeError] = useState<string>();
const pageState = useProjectsPageState({
hasProjects: projects === undefined ? undefined : projects.length > 0,
});
const { openAddProject } = pageState;
const handleProjectSearchChange = (value: string) => {
pageState.setProjectSearch(value);
};
const filteredProjects = useFilteredProjects({
projects: projects ?? [],
search: pageState.projectSearch,
});
useEffect(() => {
if (searchParams.get("resume") !== "github") {
return;
}
void (async () => {
try {
await connectGithubAction({});
setSearchParams({}, { replace: true });
openAddProject();
} catch (caughtError) {
setResumeError(
caughtError instanceof Error
? caughtError.message
: "GitHub connection failed"
);
}
})();
}, [connectGithubAction, openAddProject, searchParams, setSearchParams]);
if (projects === undefined) {
return <LoadingState />;
}
const existingSourceUrls = projects.flatMap((project) =>
project.sources.map((source) => source.url)
);
const hasProjects = projects.length > 0;
const handleProjectCreated = (projectId: string) => {
void navigate(`/?project=${projectId}`, { replace: true });
};
const handleCloseAddProject = () => {
pageState.closeAddProject();
};
return (
<main className="min-h-svh bg-[#f2f0e7] px-5 py-7 text-[#20201d] sm:px-8 sm:py-10">
<div className="mx-auto max-w-6xl">
<ProjectsHeader
hasProjects={hasProjects}
onGoHome={() => void navigate("/")}
onOpenAddProject={openAddProject}
/>
{resumeError ? (
<p className="mt-6 border border-red-300 bg-red-50 p-3 text-xs leading-5 text-red-700">
{resumeError}
</p>
) : null}
{hasProjects ? (
<ProjectsGrid
onSearchChange={handleProjectSearchChange}
projects={filteredProjects}
search={pageState.projectSearch}
totalProjects={projects.length}
/>
) : (
<section className="mt-10 max-w-2xl">
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
Begin here
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-[#20201d]">
Connect the repository where work happens.
</h2>
<p className="mt-3 max-w-xl text-base leading-7 text-[#68665e]">
Every project has its own conversation, durable context, and work
history. Start by choosing a repository.
</p>
</section>
)}
</div>
{pageState.isAddProjectOpen ? (
<AddProjectPanel
accounts={providerAccounts}
existingSourceUrls={existingSourceUrls}
onClose={handleCloseAddProject}
onProjectCreated={handleProjectCreated}
/>
) : null}
</main>
);
}

View File

@@ -2,27 +2,22 @@ import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv } from "vite-plus";
import { defineConfig } from "vite-plus";
export default defineConfig(({ mode }) => {
const envDir = path.resolve(import.meta.dirname, "../..");
const env = loadEnv(mode, envDir, "");
return {
envDir,
plugins: [tailwindcss(), reactRouter()],
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: env.CONVEX_SITE_URL,
},
export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter()],
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: "https://befitting-dalmatian-161.convex.site",
},
},
};
},
});

View File

@@ -1,63 +0,0 @@
# Caddy reverse proxy for the Zopu VDS staging agent stack.
#
# This is the ONLY public ingress to the private worker (see
# docs/DEPLOYMENT_PLAN.md §"Public surface"). It terminates TLS for the Flue
# callback hostname that Convex reaches, then forwards ONLY the documented agent
# worker paths to the internal `agents` service.
#
# Hard rules this file enforces:
# - No generic /api/* proxy and no browser-to-Flue traffic (DEPLOYMENT_PLAN.md
# line 86). Browsers talk only to Convex.
# - Only the Flue Node worker paths Convex actually calls are routed:
# /agents/zopu/<organizationId> conversation admission
# /internal/work-attempts/* work attempt execute + cancel
# /api/rivet/* RivetKit Actor gateway surface
# /workflows/* Flue workflow execution
# /health liveness probe (Compose healthcheck/CI)
# - Everything else returns 404. The private worker protocol stays narrow.
#
# Variables are injected by the `caddy` Compose service environment:
# {$AGENTS_HOST} public hostname, e.g. agents-staging.example.com
# {$AGENTS_UPSTREAM} internal upstream host:port, e.g. agents:3000
# {$ACME_EMAIL} email for the Let's Encrypt account
#
# TLS certs and the ACME account persist in the caddy-data volume. Caddy serves
# its own ACME HTTP-01 challenge responses on :80 automatically, so the :80
# block below only redirects everything else to HTTPS — worker traffic is never
# served over plain HTTP.
{
email {$ACME_EMAIL}
}
# --- TLS termination for the Flue callback hostname ---------------------------
{$AGENTS_HOST} {
encode zstd gzip
# Conversation admission: Convex POSTs to /agents/zopu/<organizationId>.
# The conversationRoute middleware requires the FLUE_DB_TOKEN bearer and the
# matching x-zopu-organization-id header.
reverse_proxy /agents/zopu/* {$AGENTS_UPSTREAM}
# Internal work-attempt execute/cancel. Requires the internalRoute bearer.
reverse_proxy /internal/work-attempts/* {$AGENTS_UPSTREAM}
# RivetKit Actor gateway surface (app.all("/api/rivet/*")). Routed so the
# runtime registry handler is reachable for actor metadata/start.
reverse_proxy /api/rivet/* {$AGENTS_UPSTREAM}
# Flue workflow execution (workflows/plan-work). Requires the workflowRoute
# bearer + x-zopu-organization-id header.
reverse_proxy /workflows/* {$AGENTS_UPSTREAM}
# Caddy routes the liveness request to the actual worker; it must not mask a
# failed worker process with a synthetic successful response.
reverse_proxy /health {$AGENTS_UPSTREAM}
# Everything else is not part of the private worker protocol.
respond 404
}
# --- HTTP → HTTPS redirect (Caddy still answers ACME challenges on :80) -------
:80 {
redir https://{host}{uri} permanent
}

View File

@@ -1,55 +0,0 @@
# Zopu VDS staging — Rivet engine + runner on host network.
#
# The agents registry and Caddy run on the HOST via systemd (not Docker).
# This compose only manages the two Rivet services, both on host networking:
#
# engine — Rivet Engine (RocksDB backend), binds 127.0.0.1:6420 on the host.
# UFW (policy DROP, only 22/80/443 open) keeps it private.
# runner — AgentOS runner, reaches the engine at 127.0.0.1:6420.
#
# With network_mode: host there are no Docker bridge networks, no port
# forwarding, and no host.docker.internal gymnastics — both services share
# the host network namespace directly.
#
# Usage:
# docker compose --env-file .env up -d
services:
engine:
image: ${ENGINE_IMAGE:?ENGINE_IMAGE must be set}
restart: unless-stopped
network_mode: host
volumes:
- type: bind
source: ${ZOPU_DEPLOY_ROOT:?ZOPU_DEPLOY_ROOT is required}/data/rivet
target: /data
environment:
RIVET__FILE_SYSTEM__PATH: /data
RIVET__AUTH__ADMIN_TOKEN: ${RIVET_ADMIN_TOKEN:?RIVET_ADMIN_TOKEN is required}
RIVET_LOG_LEVEL: ${RIVET_LOG_LEVEL:-info}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:6420/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
runner:
image: ${RUNNER_IMAGE:?RUNNER_IMAGE must be set}
restart: unless-stopped
network_mode: host
environment:
NODE_ENV: production
RIVET_RUNNER_VERSION: ${RIVET_RUNNER_VERSION:?RIVET_RUNNER_VERSION is required}
RIVET_ENVOY_VERSION: ${RIVET_RUNNER_VERSION:?RIVET_RUNNER_VERSION is required}
RIVET_ENDPOINT: http://${RIVET_NAMESPACE:-default}:${RIVET_ADMIN_TOKEN:?RIVET_ADMIN_TOKEN is required}@127.0.0.1:6420
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN:?RIVET_WORKSPACE_TOKEN is required}
AGENT_WORKSPACE_ROOT: /var/lib/zopu/workspaces
BUN_EXECUTABLE: /usr/local/bin/bun
volumes:
- type: bind
source: ${ZOPU_DEPLOY_ROOT:?ZOPU_DEPLOY_ROOT is required}/workspaces
target: /var/lib/zopu/workspaces
depends_on:
engine:
condition: service_healthy

View File

@@ -1,7 +0,0 @@
{
"$schema": "https://rivet.dev/engine-config-schema.json",
"file_system": {
"path": "/data"
},
"singleplayer": false
}

View File

@@ -1,17 +0,0 @@
{
email ops@zopu.ai
}
{$AGENTS_HOST} {
encode zstd gzip
route {
reverse_proxy /agents/zopu/* 127.0.0.1:3000
reverse_proxy /internal/work-attempts/* 127.0.0.1:3000
reverse_proxy /workflows/* 127.0.0.1:3000
reverse_proxy /health 127.0.0.1:3000
respond 404
}
}
:80 {
redir https://{host}{uri} permanent
}

View File

@@ -1,23 +0,0 @@
[Unit]
Description=Zopu agent registry
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=simple
User=zopu
Group=zopu
WorkingDirectory=/srv/zopu/source/packages/agents
EnvironmentFile=/etc/zopu/agents.env
ExecStart=/opt/zopu/node/bin/node dist/server.mjs
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/srv/zopu/workspaces
[Install]
WantedBy=multi-user.target

View File

@@ -1,4 +0,0 @@
[Service]
EnvironmentFile=/etc/zopu/agents.env
ExecStart=
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile

View File

@@ -1,168 +0,0 @@
# Deployment Architecture Recommendation
> **Status:** Proposed — research completed 2026-07-31. This supersedes the shared-Convex-environment approach in `docs/deployment.md` once implemented.
>
> **Goal:** retain an instant local iteration loop while operating one stable public staging environment with reproducible, declarative infrastructure.
## Decision
Use a deliberately split stack:
```text
Fast iteration (private development) Stable staging (public)
──────────────────────────────────── ────────────────────────
Mac + Tailscale Vercel + Contabo VDS
Web / Flue / Rivet / runner Web SSR Agents VDS
│ │ │
personal Convex dev deployment staging Convex deployment
```
| Concern | Iteration | Staging |
| --- | --- | --- |
| User-visible URL | Existing Tailscale URL on the Mac | `https://staging.<domain>` on Vercel |
| Product backend | Personal Convex **dev** deployment | Dedicated long-lived Convex deployment |
| Web | Vite HMR on the Mac | Vercel React Router SSR |
| Agent worker | Local Flue, engine, and runner | Contabo VDS: Flue, Rivet Engine, AgentOS runner |
| Delivery | Run local `pnpm dev:tailscale` | CI applies IaC then updates immutable images |
| State/credentials | Development-only | Independent staging secrets, OAuth app, and volumes |
**Do not create a second remote “fast iteration” stack.** It duplicates the slowest part of the loop—building and replacing container images—while the existing Mac/Tailscale stack already exercises the complete topology from a phone. Staging is the public link and integration gate; local development is the fast environment.
## Why this split
The repository has three materially different runtime needs:
1. **Web** is a React Router SSR Node application. Its current production artifact is `react-router build` plus `react-router-serve`, and `apps/web/Dockerfile` already serves it on Node. Vercel supports React Router SSR and streaming, and is the appropriate managed host for this stateless application. [Vercel React Router guide](https://vercel.com/docs/frameworks/frontend/react-router)
2. **Convex** owns authentication, durable product data, workflows, and reactive client projections. It is not an application container to run on the VDS. The architecture explicitly requires clients to communicate only with Convex. (`docs/TECH.md`, §1.)
3. **Flue + Rivet Engine + AgentOS runner** are persistent worker processes. The runner creates Git worktrees, installs dependencies, and host-mounts those directories into AgentOS; they require a long-lived filesystem and must stay co-located with their workspace volume. They are not appropriate for Vercel or Cloudflare Workers. [Flue Node Docker deployment](https://flueframework.com/docs/ecosystem/deploy/docker) · [Rivet runtime modes](https://rivet.dev/docs/general/runtime-modes)
Cloudflare remains valuable for authoritative DNS, TLS/DDoS controls, and optionally a later public-edge layer. It is **not** the first frontend runtime choice: moving the current Node SSR artifact to Workers requires a Cloudflare-specific React Router/workerd build and `nodejs_compat`; it does not host the private worker stack. [Cloudflare React Router guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/)
## Environment isolation — required, not optional
The present shared Convex deployment is incompatible with two simultaneously operating environments. `SITE_URL` is a single deployment-scoped Better Auth base/trusted origin and GitHub OAuth callback origin; switching it between hosts breaks the other host (`docs/deployment.md`, lines 1947).
Create these independent Convex deployments:
| Deployment | Type | Purpose |
| --- | --- | --- |
| `dev:<developer>` | Convex dev | Local loop only; each developer owns one |
| `staging` | Long-lived production-type deployment | Public integration/staging; never reused for local testing |
| Branch previews | Ephemeral preview deployment | Optional later, only for frontend/backend changes that do not need OAuth |
Convex supports a named long-lived production-type deployment (`convex deployment create staging --type prod`), per-deployment environment variables, and branch preview deployments. [Convex multiple deployments](https://docs.convex.dev/production/multiple-deployments) · [Convex environment variables](https://docs.convex.dev/production/environment-variables)
Every environment gets its own:
- `SITE_URL`, exact browser origin;
- `FLUE_URL` / `AGENT_BACKEND_URL`, pointing at only that environments Flue worker;
- `FLUE_DB_TOKEN` shared only with that environments agent service;
- model and Git provider credentials;
- GitHub OAuth application/client credentials where GitHub login is enabled.
A GitHub OAuth App permits one callback URL. Use a staging OAuth App with `https://staging.<domain>/api/auth/callback/github`; keep local development on its own OAuth app or login mechanism. Do not promise OAuth on throwaway Vercel preview domains. [GitHub OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
## Staging topology
```mermaid
flowchart LR
Browser[Browser] -->|HTTPS| Vercel[Vercel: staging web SSR]
Browser -->|queries & mutations| Convex[Convex: staging]
Vercel -->|/api/auth rewrite| ConvexSite[Convex Site auth routes]
Convex -->|service-authenticated HTTPS| Flue[Contabo: Flue Node service]
Flue --> Engine[Rivet Engine: private Docker network]
Runner[AgentOS runner: private Docker network] --> Engine
Runner --> Volumes[Persistent source mirror + workspaces]
CF[Cloudflare DNS] --> Vercel
CF --> Flue
```
### Public surface
- `staging.<domain>` → Vercel.
- `agents-staging.<domain>` → Contabo Caddy/Traefik → Flue only.
- Only ports 80/443 (and Tailscale/managed SSH) enter the VDS. Do **not** publish Rivet ports `6420` or `6421`, the AgentOS runner, workspace directories, or any engine dashboard.
- The Flue route is reachable from Convex, but its middleware must continue to require the per-environment bearer `FLUE_DB_TOKEN` and organization/turn correlation headers. This is a private worker protocol exposed narrowly for Convex callbacks—not a browser API.
- Browser clients continue to talk only to Convex. Do not restore browser-to-Flue traffic or a generic `/api/*` agent proxy.
### Private VDS services
The checked-in Compose topology should contain exactly these services:
| Service | Network exposure | Persistent data | Notes |
| --- | --- | --- | --- |
| `engine` | Internal only | `/data` bind mount | Single-node RocksDB is suitable for staging. Set an admin token; probe `:6420/health`. |
| `agents` | Caddy upstream only | Shared workspace bind mount | Runs the existing Flue Node build and owns worktree preparation; `/health` is a liveness endpoint. |
| `runner` | Internal only | Shared workspace bind mount | Separate from `agents`; needs Bun, Git, and the same worktree paths prepared by `agents`. |
| `caddy` | 80/443 only | Caddy certificate/config bind mounts | TLS for the Flue callback hostname. |
Rivets filesystem backend is explicitly appropriate for single-node deployments; multi-node/HA later requires PostgreSQL and NATS. Configure the engine with a persistent `/data` bind mount, admin token, resource limits, and a `:6420/health` probe. [Rivet Docker Compose](https://rivet.dev/docs/self-hosting/docker-compose) · [Rivet production checklist](https://rivet.dev/docs/self-hosting/production-checklist)
The runner must receive a **build-time** `RIVET_RUNNER_VERSION` derived from the CI run or immutable release revision. Rivet uses it to route new actors to the new runner and drain old actors; without it, existing actors can continue on old code.
## IaC model: Pulumi + Ansible + Compose, each at the right seam
Dokploy is intentionally excluded: it has already proved too slow and imperative for this stack. One tool should not be forced to manage three different concerns poorly.
| Layer | Source of truth | Tool | Reason |
| --- | --- | --- | --- |
| SaaS control plane | `infra/pulumi` | **Pulumi TypeScript** | Declarative Cloudflare DNS and Vercel project/domain/environment configuration; one `staging` stack now, later `production`; encrypted stack secrets and `preview`/`refresh` drift workflows. |
| VDS baseline | `infra/ansible` | **Ansible** | Idempotent OS convergence: service user, Docker, firewall, Tailscale, directories, Caddy prerequisites, and backup timer. No Pulumi SSH-command pseudo-provider. |
| VDS application topology | `deploy/compose` | **Docker Compose** | Explicit services, networks, volumes, healthchecks, images, and restart policy in the repository. This is the deployable unit. |
| Release execution | CI | **Gitea Actions or an equivalent CI runner** | Builds tagged images, pushes them to a registry, runs `ansible-playbook`, then applies a pinned Compose release and checks health. |
Pulumi state is meaningful only with a shared backend—use Pulumi Cloud or a managed/self-hosted state backend, **not** a developer-local `file://` state file. Pulumis state is what enables previews, refreshes, encrypted secret tracking, and drift detection. [Pulumi state and backends](https://www.pulumi.com/docs/iac/concepts/state-and-backends/) · [Pulumi secrets](https://www.pulumi.com/docs/iac/concepts/secrets/)
Ansible is not redundant: it makes the Contabo host reproducible without pretending that SSH command resources are declarative infrastructure. [Ansible basic concepts](https://docs.ansible.com/projects/ansible/latest/getting_started/)
## Delivery workflow
### Local iteration
1. Keep using the current root `pnpm dev:tailscale` stack and personal Convex dev deployment.
2. Use the Macs Tailscale URL for phone testing.
3. Never edit staging Convex variables, staging OAuth callback settings, or staging VDS volumes from the local loop.
4. Merge only when targeted runtime smoke testing and repository checks pass.
### Staging release
1. A merge to the staging branch (initially `master` if that is the stable branch) starts CI.
2. CI runs type checks and targeted tests.
3. CI builds the web for the **staging Convex deployment** and deploys it to the dedicated staging Vercel project. Build-time `VITE_CONVEX_URL` and `VITE_AUTH_URL` must be staging values.
4. CI builds immutable `agents` and `runner` images tagged with commit SHA; it sets `RIVET_RUNNER_VERSION` from the release identity.
5. CI runs Ansible convergence, updates the Compose release to those exact image digests, and executes `docker compose up -d`.
6. CI verifies: Vercel URL returns 200, Convex auth origin is accepted, agents health endpoint returns 200, engine health returns 200 inside the private network, and a real signed-in staging conversation receives an agent response.
7. Rollback means redeploying the previous image digests and restoring the corresponding Vercel deployment—not rebuilding mutable `latest` images.
Do not couple the Vercel deployment to Gitea-native Git integration assumptions. The repository is hosted on Gitea, so start with CI invoking the Vercel CLI/API. If a Git mirror is later introduced, Vercel previews can be enabled separately.
## Required implementation backlog
This research does **not** deploy anything. Before the first staging release, complete these changes in order:
1. Create the separate `staging` Convex deployment and its deploy key; configure deployment-scoped secrets and `SITE_URL`.
2. Correct the auth/webhook origin seams:
- Add Vercel production rewrites for `/api/auth/*` to the staging Convex Site URL; Vites current proxy only applies to local development.
- Make the Puter webhook target the staging Convex Site HTTP action directly, rather than the web origin.
3. Add Vercel React Router support and a dedicated staging deployment configuration.
4. Create the four-service Compose topology and a runner-capable image. The current agents Dockerfile starts only Flue and does not provide the dedicated Bun/Git runner service.
5. Add health routes/checks, resource limits, volume backup, image-digest deployments, and `RIVET_RUNNER_VERSION`.
6. Add `infra/pulumi` and `infra/ansible`, then CI environments with protected staging secrets.
7. Execute an end-to-end staging smoke: sign in, create/connect a project, send a conversation, and complete a disposable issue-to-PR job.
## Security and operations guardrails
- Keep environment secrets in CI environment secret stores, Pulumi encrypted configuration/ESC where appropriate, Convex deployment variables, and Vercel environment variables. Never commit `.env` files or place secrets in image layers.
- Use unique `FLUE_DB_TOKEN`, Rivet admin token, workspace token, model credential, and Git token per environment.
- The VDS runs code-writing agents. Use a dedicated service user; do not mount the host home directory; expose only scoped repository credentials to individual attempts; and keep staging separate from any production host. This follows the repository-isolation policy in `docs/TECH.md` §11.
- Back up Rivet engine state and Caddy configuration. Treat workspaces as reproducible/ephemeral unless an active job requires retention; prune completed worktrees deliberately.
- A single Flue Node instance is the correct initial staging shape. Its durable Convex adapter survives restarts, but each conversation still requires one live owner—do not add replicas until ownership routing is designed. [Flue database guide](https://flueframework.com/docs/guide/database)
## Sources
- Repository architecture: `docs/TECH.md` §§1, 1011; `docs/LOCAL_SETUP.md`; `apps/web/Dockerfile`; `packages/agents/Dockerfile`; `packages/agents/src/runtime/repository-workspace.ts`; `packages/agents/src/runtime/attempt-runner.ts`.
- [Convex environments and deployments](https://docs.convex.dev/production/multiple-deployments), [hosting on Vercel](https://docs.convex.dev/production/hosting/vercel), and [HTTP actions](https://docs.convex.dev/functions/http-actions).
- [Vercel React Router](https://vercel.com/docs/frameworks/frontend/react-router) and [Vercel environments](https://vercel.com/docs/deployments/environments).
- [Rivet self-hosted Docker Compose](https://rivet.dev/docs/self-hosting/docker-compose), [production checklist](https://rivet.dev/docs/self-hosting/production-checklist), and [version upgrades](https://rivet.dev/docs/actors/versions).
- [Flue Node/Docker deployment](https://flueframework.com/docs/ecosystem/deploy/docker) and [durable database ownership](https://flueframework.com/docs/guide/database).
- [Pulumi state](https://www.pulumi.com/docs/iac/concepts/state-and-backends/), [Pulumi secrets](https://www.pulumi.com/docs/iac/concepts/secrets/), and [Ansible](https://docs.ansible.com/projects/ansible/latest/getting_started/).

View File

@@ -1,333 +0,0 @@
# Local Setup
This guide runs the active Zopu stack locally, including the browser chat and the AgentOS issue-to-PR path.
## What runs
```text
Browser (React Router/Vite, :5173)
-> Convex Cloud (durable product state, auth, workflows)
-> Flue agent server (:3585)
-> Rivet Engine guard endpoint (:6420)
-> AgentOS registry runner
-> isolated Pi workspace mounted from the local repository
-> Gitea branch and pull request
```
The active stack does not use `repos/`; that directory is archived reference code. In particular, the old standalone server on port `3590` is not part of this setup.
## Requirements
- macOS or Linux
- Bun and Node.js matching the repository toolchain (`packages/agents` requires Node `>=22.18 <23 || >=23.6`)
- pnpm 11
- Git with SSH access to `git.openputer.com`
- [Tea](https://gitea.com/gitea/tea) authenticated to `https://git.openputer.com`
- a Convex account with access to the development deployment
- an OpenAI-completions-compatible model endpoint and API key
Install dependencies from the repository root:
```bash
bun install --frozen-lockfile
```
Confirm Git and Tea access before testing issue or PR tools:
```bash
git ls-remote origin HEAD
tea login list
tea issues list
```
The default Tea login should target `https://git.openputer.com`. Never put credentials in committed files.
## Environment
Create the local environment file:
```bash
cp .env.example .env
```
At minimum, configure these groups.
### Application and Convex
```env
CONVEX_DEPLOYMENT=dev:<deployment-name>
CONVEX_URL=https://<deployment>.convex.cloud
CONVEX_SITE_URL=https://<deployment>.convex.site
SITE_URL=http://localhost:5173
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://<deployment>.convex.cloud
```
### Flue and model provider
```env
FLUE_DB_TOKEN=<shared-random-token>
FLUE_URL=http://127.0.0.1:3585
AGENT_MODEL_PROVIDER=<provider-name>
AGENT_MODEL_NAME=<model-name>
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://<model-endpoint>/v1
AGENT_MODEL_API_KEY=<model-api-key>
AGENT_MODEL_CONTEXT_WINDOW=<positive-integer>
AGENT_MODEL_MAX_TOKENS=<positive-integer>
```
`FLUE_DB_TOKEN` must match the value stored in the Convex deployment.
### Rivet and AgentOS
```env
RIVET_ENDPOINT=http://127.0.0.1:6420
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420
RIVET_WORKSPACE_TOKEN=<random-string-at-least-32-characters>
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
```
Use the same repository checkout for `ZOPU_SOURCE_REPOSITORY`. The harness creates Git worktrees beneath `AGENT_WORKSPACE_ROOT`, copies the source checkout's `.env`, installs dependencies, and mounts the isolated checkout into AgentOS.
Port `6420` is the Rivet Engine guard endpoint used by the application. Port `6421` is an internal engine API-peer port and must not be used as `RIVET_ENDPOINT`.
### Gitea
```env
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<personal-access-token>
```
`GITEA_TOKEN` is optional for read-only agent startup but required for the complete issue-to-PR flow unless Tea and Git already have sufficient local credentials.
The legacy variables `VITE_FLUE_URL` and `VITE_ZOPU_SERVER_URL` are not used by the active web chat path.
## One-time Convex setup
On a new machine, authenticate and configure the Convex development deployment:
```bash
bun run dev:setup
```
Then configure the deployment-scoped values from `packages/backend`:
```bash
cd packages/backend
bunx convex env set SITE_URL 'http://localhost:5173'
bunx convex env set FLUE_DB_TOKEN '<same value as .env>'
bunx convex env set FLUE_URL 'http://<tailscale-ip>:3585'
```
Convex runs in the cloud, so `FLUE_URL` must be reachable from the deployment. `127.0.0.1` and `localhost` point at Convex's machine, not your laptop. For the current Mac setup, expose Flue on the Mac's Tailscale address, for example `http://100.x.y.z:3585`.
`SITE_URL` is deployment-scoped and single-valued. Set it to the exact browser origin currently being tested:
```bash
# Browser opened locally
bunx convex env set SITE_URL 'http://localhost:5173'
# Browser opened from another device over Tailscale
bunx convex env set SITE_URL 'http://<tailscale-ip>:5173'
```
If work execution uses a separate agent URL, set `AGENT_BACKEND_URL`; otherwise it falls back to `FLUE_URL`.
## Start the stack
Run each process in its own terminal. The order matters for the AgentOS path.
### 1. Start Convex development
```bash
# Publish once after backend changes
pnpm --filter @code/backend dev:once
# Watch and republish while developing
pnpm dev:server
```
Both commands run from `packages/backend` and explicitly load the repository-root `.env` with `--env-file=../../.env`. `packages/backend/.env.local` remains Convex CLI metadata for the selected deployment; keep the shared runtime settings in the root `.env`.
### 2. Start the whole stack
The root `dev` script starts Rivet Engine, the AgentOS registry runner, Convex, Flue agents, and the web app in one terminal:
```bash
pnpm dev
```
For phone or Tailscale-device access, start the stack with the web server bound to all interfaces:
```bash
pnpm dev:tailscale
```
### 3. Start Rivet Engine
If you prefer separate terminals, start the engine from the agents package:
```bash
cd packages/agents
bun run dev:engine
```
There must be exactly one local engine listening on `127.0.0.1:6420`. Do not start a second engine, and do not use `npx rivetkit dev`; that command is unavailable in the installed version.
### 4. Start the AgentOS registry runner
```bash
cd packages/agents
bun run runner
```
The runner registers the AgentOS workspace actor with Rivet Engine. Keep it running whenever a coding attempt or issue-to-PR request may execute.
### 5. Start Flue agents
For laptop-only access:
```bash
pnpm dev:agents
```
For Convex callbacks or access from another device, bind Flue to all interfaces:
```bash
pnpm dev:tailscale:agents
```
The dev scripts already use port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
If the shell already exports remote Rivet values, shell values override `.env`. Start Flue with explicit local values:
```bash
RIVET_ENDPOINT=http://127.0.0.1:6420 \
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420 \
bun run dev:tailscale:agents -- --port 3585
```
### 6. Start the web application
If you are not using the root `dev` script, start the web app separately.
For laptop-only access:
```bash
pnpm dev:web
```
For phone or other Tailscale-device access:
```bash
pnpm dev:tailscale:web
```
Open `http://localhost:5173`, or `http://<tailscale-ip>:5173` when using the Tailscale command.
## Verify the setup
Check the listeners:
```bash
curl -I http://127.0.0.1:6420
curl -I http://127.0.0.1:3585
curl -I http://127.0.0.1:5173
```
Any HTTP response confirms the process is reachable; these roots may redirect or return `404` because their functional routes live elsewhere.
Then verify behavior in order:
1. Open the web app and sign in.
2. Send a simple chat message and confirm the response streams back through Convex.
3. Ask Zopu to list open Gitea issues.
4. For the full execution path, send `Create a PR for issue #N` for an open, unassigned test issue.
5. Confirm chat immediately reports that the issue was accepted.
6. Follow the Flue server logs for `[create_pr_for_issue]` and wait for a `completed` line with the PR URL.
7. Confirm the branch and pull request in Gitea.
The PR pipeline is asynchronous: the initial chat turn acknowledges acceptance, while AgentOS implements, commits, pushes, and opens the pull request in the background.
## Request flow
```text
Web sends a conversation mutation to Convex
-> Convex persists the exact message
-> Convex dispatches to FLUE_URL/agents/zopu/<organizationId>
-> Flue runs the Zopu agent and its typed tools
-> responses return to Convex
-> the web observes Convex's reactive projection
For code execution:
-> Flue calls the local AgentOS harness
-> the harness creates an isolated Git worktree
-> a Rivet actor boots an AgentOS VM and mounts the worktree
-> Pi implements the issue and produces a candidate revision
-> the host pushes a unique branch
-> Tea creates a Gitea pull request
```
## Common failures
### Chat stays pending or reports that Flue is unavailable
- Confirm Flue is listening on `3585`.
- Confirm the Convex deployment's `FLUE_URL` uses a host-reachable address, not `localhost`.
- Confirm `FLUE_DB_TOKEN` is identical in `.env` and the Convex deployment.
### Sign-in fails or the browser reports CORS errors
Set Convex `SITE_URL` to the exact browser origin. The shared development deployment trusts only the current single value.
### AgentOS fails while mounting the repository
- Confirm both Rivet endpoints use port `6420`.
- Confirm the engine and `bun run runner` are both running.
- Confirm `ZOPU_SOURCE_REPOSITORY` contains `.git`.
- Confirm `AGENT_WORKSPACE_ROOT` is writable.
- Restart the runner after changing AgentOS registry configuration; Flue hot reload is not enough.
The harness clients require CBOR encoding for host-directory mount descriptors. Do not remove the `encoding: "cbor"` configuration in `packages/agents/src/runtime/agent-os.ts`.
### AgentOS reports an ACP completed-message resource limit
The workspace registry raises `limits.acp.maxCompletedMessageBytes` for long Pi coding runs. Restart the runner so the updated registry configuration is registered with Rivet Engine.
### Flue connects to a remote Rivet deployment unexpectedly
Environment variables exported by the shell override values loaded from `.env`. Start Flue and the runner with explicit `RIVET_ENDPOINT` and `RIVET_PUBLIC_ENDPOINT` values pointing to `127.0.0.1:6420`.
### Gitea issue or PR commands fail
Run:
```bash
tea login list
git ls-remote origin HEAD
tea issues list
```
Confirm the default Tea login, SSH key, repository remote, and token all target `git.openputer.com`.
## Repository checks
After changing source or configuration:
```bash
bunx ultracite check <changed-files>
bun run check-types
bun run check
```
For agent-only changes, run `bun run check-types` from `packages/agents` before the root check.
See also:
- [`TECH.md`](TECH.md) for architecture and ownership boundaries
- [`deployment.md`](deployment.md) for the shared staging deployment
- [Rivet AgentOS quickstart](https://rivet.dev/docs/agent-os/quickstart)
- [Flue local development](https://flue.dev/docs/cli/dev)

View File

@@ -5,7 +5,7 @@
> **Status:** Working source of truth
> **Audience:** product, design, engineering, agents
> **Normative words:** **MUST** = required, **SHOULD** = default, **MAY** = optional
> **Product boundary:** this file defines _what and why_, not implementation details.
> **Product boundary:** this file defines *what and why*, not implementation details.
## 1. Thesis
@@ -35,7 +35,7 @@ Technical founders and small engineering teams working in one Git repository.
```text
Project chat
→ actionable Signal
ready Work Definition
approved Work Definition
→ lightweight Design Packet
→ vertical slices
→ isolated coding run
@@ -90,7 +90,7 @@ A Work card is the durable, inspectable representation of one desired outcome. I
All decisions requiring a human are first-class objects:
- clarify intent;
- definition/design persist automatically;
- approve definition/design;
- grant permission;
- select an alternative;
- review a slice;
@@ -249,18 +249,18 @@ Canonical knowledge updates require review.
High-quality software is evaluated across:
| Dimension | Required question |
| ----------------- | -------------------------------------------------------- |
| Correctness | Does behavior satisfy the accepted criteria? |
| Regression safety | Did existing behavior remain valid? |
| Maintainability | Does the change preserve understandable boundaries? |
| Security | Are permissions, secrets, data, and dependencies safe? |
| Reliability | Are failures and edge cases handled? |
| Operability | Can it be deployed, observed, debugged, and rolled back? |
| Performance | Are expected resource limits preserved? |
| UX | Does the actual user path work? |
| Traceability | Can claims be tied to evidence and revisions? |
| Reversibility | Can risk be disabled or rolled back? |
| Dimension | Required question |
|---|---|
| Correctness | Does behavior satisfy the accepted criteria? |
| Regression safety | Did existing behavior remain valid? |
| Maintainability | Does the change preserve understandable boundaries? |
| Security | Are permissions, secrets, data, and dependencies safe? |
| Reliability | Are failures and edge cases handled? |
| Operability | Can it be deployed, observed, debugged, and rolled back? |
| Performance | Are expected resource limits preserved? |
| UX | Does the actual user path work? |
| Traceability | Can claims be tied to evidence and revisions? |
| Reversibility | Can risk be disabled or rolled back? |
### Completion rule
@@ -283,7 +283,7 @@ The implementation worker MUST NOT be the sole authority on completion.
Builder → candidate output
Verifier → independent evidence
Resolver → completion decision
Human → merge/release authorization
Human → policy-defined approvals
```
### Risk lanes
@@ -309,7 +309,7 @@ definition → combined design → 13 slices → verify → review → PR
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
```text
definition → design → ready → merge/release
definition approval → architecture approval → program-design approval
→ one slice at a time → staged release → observation/rollback gate
```
@@ -356,8 +356,8 @@ Humans should spend attention on high-leverage decisions, not supervise tool cal
Preferred gates:
- Work Definition (no approval gate);
- Design Packet (no approval gate);
- Work Definition approval;
- Design Packet approval for standard/critical work;
- slice review when risk requires it;
- merge/release approval;
- resolution of ambiguity or policy exceptions.

View File

@@ -16,12 +16,10 @@ Dense context set for product development and coding agents.
9. evaluation.md — quality measurement and improvement
```
For a runnable development environment, see [`LOCAL_SETUP.md`](LOCAL_SETUP.md).
## Use by role
| Role | Minimum context |
| --- | --- |
|---|---|
| Product/definition agent | agent-context, product, glossary, dev-loop |
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |

View File

@@ -198,7 +198,9 @@ All external/model payloads MUST be decoded with schemas at boundaries.
```text
Proposed
→ Defining
→ AwaitingDefinitionApproval
→ Designing
→ AwaitingDesignApproval
→ Ready
→ ExecutingSlice
→ VerifyingSlice
@@ -240,7 +242,7 @@ Initial central aggregate:
- Design Packet and versions;
- slice/step graph;
- Resolver state;
- questions;
- approvals/questions;
- artifact references;
- result.
@@ -423,7 +425,7 @@ HarnessRuntime
v0 selects one harness. Prefer mature coding harnesses for repository exploration/edit/test loops; use custom FLUE agents for product-specific reasoning.
Zopu owns Work, branches, worktrees, budgets, artifacts, and completion. Harness owns one bounded implementation loop.
Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completion. Harness owns one bounded implementation loop.
## 10. Runtime strategy
@@ -578,7 +580,7 @@ Never create a “verified PR” from a different SHA than the verified candidat
Review package:
- original intent;
- current definition/design;
- approved definition/design;
- slice narrative;
- meaningful diffs;
- screenshots/video;
@@ -606,7 +608,7 @@ Production release requires explicit policy, rollout plan, health signals, and r
- no long-lived model/Git secrets in workspace files;
- network egress policy;
- artifact access authorization;
- immutable audit trail for all Work events;
- immutable audit trail for approvals;
- tool allowlist per Kit;
- destructive tools denied by default;
- merge/deploy human-gated initially;
@@ -661,7 +663,7 @@ Keep Cube control APIs private; expose only authorized preview paths. Rivet and
The architecture is proven when one real repository supports:
```text
message → Signal → proposed Work → ready Work
message → Signal → approved Work → approved Design Packet
→ one slice → isolated harness run → independent verification
→ verified commit → real PR → human response/resume
```

View File

@@ -195,9 +195,9 @@ Prefer explicit state machines over booleans.
Bad:
```ts
isRunning: boolean;
isDone: boolean;
hasError: boolean;
isRunning: boolean
isDone: boolean
hasError: boolean
```
Good:
@@ -213,7 +213,7 @@ type AttemptStatus =
| "verification_failed"
| "budget_exhausted"
| "cancelled"
| "permanent_failure";
| "permanent_failure"
```
Transitions must validate current version/state.
@@ -281,44 +281,38 @@ Design replaceable boundaries, then ship one path.
```md
## Implemented
- ...
## Behavior
- ...
## Verification run
- `command` — pass/fail
- ...
## Evidence
- candidate SHA/artifacts
- ...
## Design deviations
- none / ...
## Remaining risks or blockers
- none / ...
```
## 16. Required reading by task type
| Task | Read |
| ------------------ | ---------------------------------------- |
| product/domain | product.md, glossary.md |
| UI | design.md, product.md |
| actors/state | tech.md, glossary.md |
| orchestration | dev-loop.md, tech.md |
| current sequencing | slices.md, dev-plan.md |
| verification/evals | evaluation.md, dev-loop.md |
| provider adapter | tech.md plus provider docs |
| broad feature | agent-context.md + all relevant sections |
| Task | Read |
|---|---|
| product/domain | product.md, glossary.md |
| UI | design.md, product.md |
| actors/state | tech.md, glossary.md |
| orchestration | dev-loop.md, tech.md |
| current sequencing | slices.md, dev-plan.md |
| verification/evals | evaluation.md, dev-loop.md |
| provider adapter | tech.md plus provider docs |
| broad feature | agent-context.md + all relevant sections |
## 17. Stop and escalate when

View File

@@ -1,15 +1,17 @@
# Auth Proxy — Production Ingress Requirement
The application uses **same-origin authentication**: the browser and React Router SSR both hit `/api/auth/*` on the public application domain. This keeps cookies first-party, avoids cross-origin credentials, and gives development and production the same API surface.
The application uses **same-origin authentication**: the browser and React Router SSR both hit
`/api/auth/*` on the public application domain. This keeps cookies first-party, avoids
cross-origin credentials, and gives development and production the same API surface.
## Required production route
At the public application domain, route:
| Prefix | Target |
| ------------- | --------------------- |
| `/api/auth/*` | Convex HTTP site |
| `/*` | React Router frontend |
| Prefix | Target |
|---|---|
| `/api/auth/*` | Convex HTTP site |
| `/*` | React Router frontend |
### Caddy
@@ -29,7 +31,8 @@ zopu.example.com {
### Dokploy / Traefik
Create a higher-priority path router for `/api/auth` that forwards to the external Convex site URL. Ensure it:
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
site URL. Ensure it:
- Preserves the original browser `Cookie` header
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
@@ -41,7 +44,8 @@ Create a higher-priority path router for `/api/auth` that forwards to the extern
## Convex environment
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex site URL:
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
site URL:
```bash
# Production
@@ -54,20 +58,15 @@ npx convex env set SITE_URL 'http://100.101.157.28:5173'
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
This value is Better Auth's public `baseURL` and `trustedOrigins`. It must be the browser origin because Better Auth writes OAuth state cookies and receives provider callbacks through the same-origin proxy. `CONVEX_SITE_URL` remains the internal Convex HTTP site URL.
## One origin per shared OAuth deployment
GitHub OAuth Apps expose one authorization callback URL. The value must exactly equal `<SITE_URL>/api/auth/callback/github`, including scheme, host, port, and path. A GitHub authorization request whose `redirect_uri` differs returns `Invalid Redirect URI` before the user can grant access.
`SITE_URL` is also single-valued in a Convex deployment. Therefore, two browser origins that share one deployment—such as Tailscale development and Puter staging—cannot use GitHub OAuth at the same time. Either switch both the Convex `SITE_URL` and the GitHub OAuth App callback together before testing the other origin, or give each public environment its own Convex deployment and OAuth App.
Never set Better Auth's `baseURL` to `CONVEX_SITE_URL` for the browser flow. The browser creates the OAuth state cookie on `SITE_URL`; a provider callback sent directly to `CONVEX_SITE_URL` cannot read that cookie and fails with `state_mismatch`.
This value populates Better Auth's `trustedOrigins`. The Convex deployment also uses
`CONVEX_SITE_URL` as the internal `baseURL` for route registration; that value is the HTTP
site URL (e.g. `https://befitting-dalmatian-161.convex.site`).
## Why same-origin
1. **First-party cookies.** No `SameSite=None`, no third-party cookie restrictions.
2. **SSR consistency.** The React Router server uses the same auth surface the browser sees; no cross-host credential translation.
2. **SSR consistency.** The React Router server uses the same auth surface the browser
sees; no cross-host credential translation.
3. **No CORS complexity.** The browser talks only to its own origin.
4. **The reverse proxy handles the cross-host hop server-side.**

View File

@@ -1,10 +1,11 @@
# Deployment Notes
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`). Each runs its own web server, Flue agents process, and `.env` file.
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`).
Each runs its own web server, Flue agents process, and `.env` file.
## Environments
| | Local Mac Dev | Cheaptricks Staging |
| | Local Mac Dev | Cheaptricks Staging |
| --- | --- | --- |
| SSH alias | (local) | `cheaptricks` |
| Repo path | `/Users/puter/Workspace/zopu/code` | `/workspace/code` |
@@ -18,9 +19,12 @@ Two active environments share one Convex deployment (`dev:befitting-dalmatian-16
## Shared Convex deployment
Deployment name: `dev:befitting-dalmatian-161` Convex URL: `https://befitting-dalmatian-161.convex.cloud` Convex Site URL: `https://befitting-dalmatian-161.convex.site`
Deployment name: `dev:befitting-dalmatian-161`
Convex URL: `https://befitting-dalmatian-161.convex.cloud`
Convex Site URL: `https://befitting-dalmatian-161.convex.site`
Convex env vars are deployment-scoped, not per-machine. Authenticate from any machine with `npx convex dev` inside `packages/backend`.
Convex env vars are deployment-scoped, not per-machine. Authenticate from any
machine with `npx convex dev` inside `packages/backend`.
Key Convex env var:
@@ -28,7 +32,9 @@ Key Convex env var:
SITE_URL = <the origin Better Auth should trust>
```
This controls `trustedOrigins` in the Better Auth config (`packages/backend/convex/auth.ts`). It must match the URL the browser actually visits, or sign-in fails silently with a CORS rejection.
This controls `trustedOrigins` in the Better Auth config
(`packages/backend/convex/auth.ts`). It must match the URL the browser
actually visits, or sign-in fails silently with a CORS rejection.
When switching between environments:
@@ -42,10 +48,6 @@ npx convex env set SITE_URL 'http://100.101.157.28:5173'
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
### OAuth consequence
`SITE_URL` drives both Better Auth's public callback origin and the single GitHub OAuth App callback. When switching the shared deployment between the local and staging origins, also update the GitHub OAuth App's **Authorization callback URL** to `<SITE_URL>/api/auth/callback/github` before attempting a GitHub connection. GitHub OAuth Apps allow one callback URL; use distinct Convex deployments and OAuth Apps if both environments must operate concurrently.
## Local Mac Dev `.env`
```env
@@ -141,24 +143,16 @@ zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
# Must precede the generic /api route: preserves /api/auth/* and cookies.
handle /api/auth/* {
reverse_proxy https://befitting-dalmatian-161.convex.site {
header_up Host befitting-dalmatian-161.convex.site
}
}
handle_path /api/* {
reverse_proxy 127.0.0.1:3585
}
handle {
reverse_proxy 127.0.0.1:5173
}
reverse_proxy 127.0.0.1:5173
}
```
`/api/auth/*` is the same-origin Better Auth proxy to Convex. The generic `/api/*` route serves Flue at port `3585`; it must be evaluated only after the authentication route.
`/api/*` routes to the Flue agents process (port 3585).
Everything else routes to the web dev server (port 5173).
Reload after changes:
@@ -168,7 +162,8 @@ sudo systemctl reload caddy
### Vite allowedHosts
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or Vite rejects requests arriving through the Caddy domain.
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or
Vite rejects requests arriving through the Caddy domain.
### Start staging dev
@@ -216,18 +211,23 @@ Both environments use the same model via the Cheaptricks AI gateway:
- Multimodal: text + image input
The `AGENT_MODEL_BASE_URL` differs:
- Local Mac: uses the external gateway URL
- Cheaptricks: uses `https://ai.cheaptricks.puter.wtf/v1` (local to the box)
## Known gotchas
1. **Convex `SITE_URL` is single-valued and is the OAuth callback origin.** Switch it together with the GitHub OAuth App's single callback URL when moving between local and staging. Running both origins concurrently requires separate Convex deployments and OAuth Apps.
1. **Convex SITE_URL is single-valued.** Only one origin can be trusted at a
time. Switch it when moving between local and staging, or sign-in breaks
silently with a CORS error in the browser console.
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed via `server.allowedHosts` in `apps/web/vite.config.ts`.
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed
via `server.allowedHosts` in `apps/web/vite.config.ts`.
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at 3583/13100. Current ports are 3585 (Flue) and 5173 (web).
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at
3583/13100. Current ports are 3585 (Flue) and 5173 (web).
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo ships `.env.example` as the template.
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo
ships `.env.example` as the template.
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside `packages/backend` on each new machine to authenticate the CLI.
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside
`packages/backend` on each new machine to authenticate the CLI.

View File

@@ -644,7 +644,7 @@ Answers are durable Decisions and resume the same Work.
## 17. Resolver decision table
| Condition | Resolver action |
| --- | --- |
|---|---|
| executable slice exists | start bounded Attempt |
| transient infrastructure/model failure | retry by policy |
| candidate fails checks | create repair Attempt with evidence |

View File

@@ -6,25 +6,25 @@ You have shell access and may use the Paseo CLI to create and manage other Codex
All child agents must use:
--provider codex/glm-5.2
--provider codex/glm-5.2
The current repository is the Zopu monorepo. It already contains product documentation, design documentation, technical documentation, Effect primitives, Convex backend code, web/mobile/desktop applications, agent code, project-management primitives, Signals, and partial AgentOS/Rivet integration.
The starting branch is expected to be:
feat/projects-backend
feat/projects-backend
Verify this rather than blindly assuming it.
The final integration branch should be:
dogfood/v0
dogfood/v0
Your job is to manage the entire implementation through parallel isolated worktrees.
======================================================================
1. PRODUCT OBJECTIVE ======================================================================
1. PRODUCT OBJECTIVE
======================================================================
Ship the smallest complete Zopu dogfooding loop for the Zopu repository itself:
@@ -67,11 +67,25 @@ Ship the smallest complete Zopu dogfooding loop for the Zopu repository itself:
The completed proof should be something like:
User: “Add a health endpoint that exposes the current build commit.”
User:
“Add a health endpoint that exposes the current build commit.”
Result: ✓ exact message evidence stored ✓ Signal created ✓ Work Unit created or updated ✓ work started ✓ Orb provisioned ✓ OpenCode session started ✓ repository changed ✓ tests executed ✓ branch committed and pushed ✓ pull request opened ✓ work card updated ✓ contextual follow-up reaches the active work
Result:
✓ exact message evidence stored
✓ Signal created
✓ Work Unit created or updated
✓ work started
✓ Orb provisioned
✓ OpenCode session started
✓ repository changed
✓ tests executed
✓ branch committed and pushed
✓ pull request opened
✓ work card updated
✓ contextual follow-up reaches the active work
====================================================================== 2. HARD SCOPE
======================================================================
2. HARD SCOPE
======================================================================
This is a narrow dogfooding implementation.
@@ -114,7 +128,6 @@ ProjectIssue may remain the backend representation of a Work Unit for this versi
Project events may represent execution steps and activity.
Project artifacts may represent:
- work.md;
- steps.md;
- agent reports;
@@ -123,7 +136,8 @@ Project artifacts may represent:
- commits;
- pull requests.
====================================================================== 3. OPERATING RULES
======================================================================
3. OPERATING RULES
======================================================================
Before changing anything:
@@ -183,18 +197,22 @@ When an agent is blocked:
Do not stop after spawning agents. You must monitor and integrate them.
====================================================================== 4. INITIAL BRANCH PREPARATION
======================================================================
4. INITIAL BRANCH PREPARATION
======================================================================
Inspect the current branch.
If currently on feat/projects-backend:
git pull --ff-only git switch -c dogfood/v0 git push -u origin dogfood/v0
git pull --ff-only
git switch -c dogfood/v0
git push -u origin dogfood/v0
If dogfood/v0 already exists:
git switch dogfood/v0 git pull --ff-only
git switch dogfood/v0
git pull --ff-only
If the expected branch is different, inspect the repository history and choose the branch containing the current projects backend work. Do not discard existing uncommitted work.
@@ -202,20 +220,23 @@ Run the baseline checks before spawning agents. Record failures but do not spend
Suggested baseline:
bun install bun run check
bun install
bun run check
Also inspect package-specific scripts and use the actual commands defined by the repository.
====================================================================== 5. PASEO ORCHESTRATION
======================================================================
5. PASEO ORCHESTRATION
======================================================================
Verify Paseo first:
paseo status paseo ls
paseo status
paseo ls
If the daemon is unavailable, inspect:
tail -n 200 ~/.paseo/daemon.log
tail -n 200 ~/.paseo/daemon.log
Do not continue spawning until Paseo works.
@@ -224,24 +245,24 @@ For every child lane:
1. Create a worktree workspace:
paseo workspace create \
--isolation worktree \
--mode branch-off \
--new-branch <branch> \
--base dogfood/v0 \
--title "<title>" \
--json
--isolation worktree \
--mode branch-off \
--new-branch <branch> \
--base dogfood/v0 \
--title "<title>" \
--json
2. Read the returned workspaceId.
3. Start the child agent:
paseo run \
--provider codex/glm-5.2 \
--workspace <workspaceId> \
--title "<title>" \
--background \
--json \
"<full lane prompt>"
--provider codex/glm-5.2 \
--workspace <workspaceId> \
--title "<title>" \
--background \
--json \
"<full lane prompt>"
4. Save:
- branch;
@@ -252,27 +273,33 @@ For every child lane:
Maintain an orchestration note in a temporary file outside committed product code, for example:
/tmp/zopu-dogfood-orchestration.md
/tmp/zopu-dogfood-orchestration.md
Track:
| Lane | Branch | Workspace ID | Agent ID | Status | PR |
| ---- | ------ | ------------ | -------- | ------ | --- |
| Lane | Branch | Workspace ID | Agent ID | Status | PR |
|------|--------|--------------|----------|--------|----|
Use:
paseo ls paseo inspect <agentId> paseo logs <agentId> paseo send <agentId> "<focused instruction>" paseo wait <agentId>
paseo ls
paseo inspect <agentId>
paseo logs <agentId>
paseo send <agentId> "<focused instruction>"
paseo wait <agentId>
Do not block waiting on one child while others are still running. Poll all first-wave agents periodically.
====================================================================== 6. SHARED CHILD-AGENT PREFIX
======================================================================
6. SHARED CHILD-AGENT PREFIX
======================================================================
Prepend the following instructions to every child prompt:
You are implementing one isolated lane of the Zopu dogfood v0 loop.
Repository base branch: dogfood/v0
Repository base branch:
dogfood/v0
Your branch is already isolated in a Paseo Git worktree.
@@ -320,30 +347,29 @@ Before finishing:
- PR URL or exact PR creation instructions;
- remaining limitations.
====================================================================== 7. FIRST-WAVE PARALLEL LANES
======================================================================
7. FIRST-WAVE PARALLEL LANES
======================================================================
Spawn all four first-wave agents immediately.
---
----------------------------------------------------------------------
LANE A — PROJECT CHAT, SIGNALS AND WORK ROUTING
----------------------------------------------------------------------
Branch:
dogfood/zopu-orchestrator
dogfood/zopu-orchestrator
Title:
Zopu Signals Orchestrator
Zopu Signals Orchestrator
Prompt:
Implement the project-scoped Zopu chat, Signal extraction and work-routing loop.
Inspect the existing:
- Zopu agent;
- Signals primitive;
- ProjectIssue primitive;
@@ -395,10 +421,10 @@ Rules:
Acceptance examples:
Message: “The work-unit card should expose the generated PR.”
Message:
“The work-unit card should expose the generated PR.”
Expected:
- exact message persisted;
- Signal created;
- attached to an existing UI/workspace issue if relevant;
@@ -423,18 +449,17 @@ Likely ownership:
- packages/backend/convex/schema*
- Signal/project-issue primitives only as required.
---
----------------------------------------------------------------------
LANE B — ORB RUNTIME FOUNDATION
----------------------------------------------------------------------
Branch:
dogfood/orb-runtime
dogfood/orb-runtime
Title:
AgentOS OpenCode Orb Runtime
AgentOS OpenCode Orb Runtime
Prompt:
@@ -500,7 +525,6 @@ Keep product-domain concepts separate from infrastructure leases.
Do not make AgentOS VM state equal the work-unit state.
Actor identity must include:
- project;
- issue/work unit;
- run.
@@ -553,23 +577,21 @@ Document:
- current limitations.
Do not modify:
- Zopu chat behavior;
- project-manager delegation;
- web UI.
---
----------------------------------------------------------------------
LANE C — WEB WORKSPACE AND WORK CARDS
----------------------------------------------------------------------
Branch:
dogfood/web-workspace
dogfood/web-workspace
Title:
Zopu Web Work OS
Zopu Web Work OS
Prompt:
@@ -591,21 +613,18 @@ Product projection:
Required interface:
Header:
- Zopu;
- current project;
- project/runtime connection status;
- basic project switcher if already supported.
Main surface:
- global project conversation;
- active Work Unit cards;
- lightweight Signals display;
- persistent composer.
Collapsed Work Unit card:
- title;
- latest summary;
- linked Signal count;
@@ -616,7 +635,6 @@ Collapsed Work Unit card:
- needs-input indicator when available.
Expanded Work Unit:
- objective;
- linked Signals and provenance;
- current plan or steps;
@@ -687,18 +705,17 @@ Tests:
- empty state;
- needs-input display.
---
----------------------------------------------------------------------
LANE D — SINGLE-NODE EXECUTION DEPLOYMENT
----------------------------------------------------------------------
Branch:
dogfood/runtime-deploy
dogfood/runtime-deploy
Title:
Zopu Dedicated Runtime Deployment
Zopu Dedicated Runtime Deployment
Prompt:
@@ -777,7 +794,8 @@ The deployment must be reproducible on a clean Debian host.
Do not require the developers MacBook to remain online after deployment.
====================================================================== 8. FIRST-WAVE MONITORING
======================================================================
8. FIRST-WAVE MONITORING
======================================================================
After spawning all four first-wave agents:
@@ -797,15 +815,16 @@ Use concise interventions.
Examples:
paseo send <agentId> "Keep ProjectIssue as the v0 Work Unit. Do not introduce a second generic work-unit schema."
paseo send <agentId> "Keep ProjectIssue as the v0 Work Unit. Do not introduce a second generic work-unit schema."
paseo send <agentId> "Do not wire project-manager yet. Finish the Orb port, adapter and proof fixture only."
paseo send <agentId> "Do not wire project-manager yet. Finish the Orb port, adapter and proof fixture only."
paseo send <agentId> "Use existing Convex contracts. Avoid backend schema changes in the UI lane."
paseo send <agentId> "Use existing Convex contracts. Avoid backend schema changes in the UI lane."
Wait until each first-wave agent is idle, then inspect:
paseo inspect <agentId> paseo logs <agentId>
paseo inspect <agentId>
paseo logs <agentId>
Review each branch yourself.
@@ -823,7 +842,8 @@ For every lane:
Do not merge a failing lane merely because the child says it works.
====================================================================== 9. FIRST-WAVE MERGE PROCESS
======================================================================
9. FIRST-WAVE MERGE PROCESS
======================================================================
Recommended merge order:
@@ -835,7 +855,11 @@ Recommended merge order:
Before every merge:
git switch dogfood/v0 git pull --ff-only git fetch origin git log --oneline --decorate --max-count=10 origin/<branch> git diff dogfood/v0...origin/<branch>
git switch dogfood/v0
git pull --ff-only
git fetch origin
git log --oneline --decorate --max-count=10 origin/<branch>
git diff dogfood/v0...origin/<branch>
Run lane-specific checks.
@@ -843,28 +867,31 @@ Merge using the repositorys normal PR workflow when possible.
If merging locally:
git merge --no-ff origin/<branch>
git merge --no-ff origin/<branch>
Resolve conflicts conservatively.
After each merge:
bun install run targeted checks git push origin dogfood/v0
bun install
run targeted checks
git push origin dogfood/v0
Do not squash away useful commits unless repository policy requires squashing.
====================================================================== 10. SECOND-WAVE LANE — WIRE PROJECT MANAGER TO ORBS
======================================================================
10. SECOND-WAVE LANE — WIRE PROJECT MANAGER TO ORBS
======================================================================
Only spawn this lane after the Orb runtime branch is integrated into dogfood/v0.
Branch:
dogfood/orb-wiring
dogfood/orb-wiring
Title:
Project Manager Orb Wiring
Project Manager Orb Wiring
Create its Paseo workspace from the updated dogfood/v0 branch.
@@ -917,13 +944,20 @@ Refactor the current Git lifecycle so it can be invoked from the Orb/application
Failure mapping:
- runtime unavailable: infrastructure failure with clear reason;
- missing user context: needs input;
- failed tests that can be repaired: agent continues;
- unrecoverable repeated failure: failed with logs and evidence;
- cancellation: terminate OpenCode and sandbox processes;
- Git rejection: preserve commit and expose exact failure;
- PR creation failure: retain pushed branch and retry safely.
- runtime unavailable:
infrastructure failure with clear reason;
- missing user context:
needs input;
- failed tests that can be repaired:
agent continues;
- unrecoverable repeated failure:
failed with logs and evidence;
- cancellation:
terminate OpenCode and sandbox processes;
- Git rejection:
preserve commit and expose exact failure;
- PR creation failure:
retain pushed branch and retry safely.
Idempotency:
@@ -947,7 +981,8 @@ Tests:
Do not add multi-agent parallel execution.
====================================================================== 11. SECOND-WAVE REVIEW AND MERGE
======================================================================
11. SECOND-WAVE REVIEW AND MERGE
======================================================================
Monitor the orb-wiring agent closely.
@@ -972,18 +1007,19 @@ Review and merge it into dogfood/v0 only after:
- idempotency is demonstrated;
- no secrets appear in logs or artifacts.
====================================================================== 12. FINAL LANE — END-TO-END DOGFOOD INTEGRATION
======================================================================
12. FINAL LANE — END-TO-END DOGFOOD INTEGRATION
======================================================================
After all implementation lanes are merged, spawn the final integration agent.
Branch:
dogfood/e2e-integration
dogfood/e2e-integration
Title:
Zopu Dogfood End-to-End
Zopu Dogfood End-to-End
Prompt:
@@ -1064,7 +1100,7 @@ Verification matrix:
Produce:
docs/DOGFOOD_V0.md
docs/DOGFOOD_V0.md
It must contain:
@@ -1083,7 +1119,8 @@ It must contain:
Commit, push and open a PR targeting dogfood/v0.
====================================================================== 13. FINAL INTEGRATION RESPONSIBILITIES
======================================================================
13. FINAL INTEGRATION RESPONSIBILITIES
======================================================================
After the final agent completes:
@@ -1109,7 +1146,8 @@ If the full live path cannot complete:
The final system must not claim that a PR exists unless it exists in Git.
====================================================================== 14. QUALITY GATES
======================================================================
14. QUALITY GATES
======================================================================
A lane is not done merely because code was generated.
@@ -1146,7 +1184,8 @@ The complete v0 must satisfy:
- PR is created;
- PR is visible in the UI.
====================================================================== 15. RESOURCE AND CONCURRENCY LIMITS
======================================================================
15. RESOURCE AND CONCURRENCY LIMITS
======================================================================
For this v0:
@@ -1162,11 +1201,12 @@ For this v0:
When agents are no longer needed:
paseo archive <agentId>
paseo archive <agentId>
Do not archive them until their logs and work have been reviewed.
====================================================================== 16. COMMUNICATION WITH THE USER
======================================================================
16. COMMUNICATION WITH THE USER
======================================================================
Do not repeatedly ask the user to choose between reasonable implementation details.
@@ -1190,7 +1230,8 @@ When requesting input, provide:
Continue supervising all unblocked lanes while waiting.
====================================================================== 17. FINAL REPORT
======================================================================
17. FINAL REPORT
======================================================================
When finished, provide a concise but complete report:
@@ -1213,7 +1254,6 @@ When finished, provide a concise but complete report:
## Integrated branches
For each branch:
- purpose;
- final commit;
- PR;

View File

@@ -157,12 +157,12 @@ Observe real Work with human decisions, merge outcomes, incidents, and post-rele
Score each candidate run 03 per dimension.
| Score | Meaning |
| ----- | --------------------------------- |
| 0 | unsafe/incorrect/missing |
| 1 | substantial human repair required |
| 2 | acceptable with minor correction |
| 3 | correct, complete, reviewable |
| Score | Meaning |
|---|---|
| 0 | unsafe/incorrect/missing |
| 1 | substantial human repair required |
| 2 | acceptable with minor correction |
| 3 | correct, complete, reviewable |
Dimensions:

View File

@@ -1,147 +0,0 @@
# Git Provider Setup
This document covers manual setup for GitHub OAuth, Puter Git (Gitea), webhooks, and Convex environment variables.
> **Never place admin or user tokens in checked-in `.env` files or browser variables.** All secrets must be Convex environment variables set via `npx convex env set`.
## GitHub OAuth Application
1. Go to GitHub **Settings → Developer settings → OAuth Apps** and open the app whose client ID will be stored in Convex.
2. Set the application name and homepage URL. The homepage URL should be the current `SITE_URL`.
3. Set the single **Authorization callback URL** to exactly:
```text
<SITE_URL>/api/auth/callback/github
```
GitHub OAuth Apps support one callback URL. Scheme, host, port, and path must match the `redirect_uri` sent by Better Auth exactly.
4. Generate a client secret.
5. Set the credentials on the Convex deployment—not only in the local `.env` file:
```bash
cd packages/backend
npx convex env set GITHUB_CLIENT_ID <your-client-id>
npx convex env set GITHUB_CLIENT_SECRET <your-client-secret>
npx convex env list
```
The configured `SITE_URL`, GitHub callback URL, and browser URL are one unit. For a shared Convex deployment, switch all three before testing a different public origin. See [Auth Proxy](./auth-proxy.md#one-origin-per-shared-oauth-deployment) for the deployment boundary.
### Requested scopes
Zopu requests GitHub identity and private-repository access: `read:user`, `user:email`, `repo`, and `read:org`. `repo` grants access to private repositories; `read:org` permits organization repository discovery.
## GitHub Webhook (manual setup for OAuth-based version)
1. Go to the GitHub repository Settings > Webhooks > Add webhook.
2. Payload URL: `<CONVEX_SITE_URL>/api/git/webhooks/github`
3. Content type: `application/json`
4. Secret: generate a strong random string and set it as:
```
npx convex env set GITHUB_WEBHOOK_SECRET <your-webhook-secret>
```
5. Select individual events:
- Push
- Repository (created, deleted, transferred, renamed, visibility)
- Delete
- Create
- Public
- Fork
6. Do not select "Send me everything."
## Puter Git (Gitea) Admin Token
1. As a Gitea admin, go to Settings > Applications > Generate New Token.
2. Select scopes: `write:admin`, `write:organization`, `write:repository`.
3. Set the token:
```
npx convex env set PUTER_GIT_ADMIN_TOKEN <your-admin-token>
```
This token is used only for platform administration (user creation, organization creation, repository creation, migration). It is never used for end-user operations.
## Puter Git Webhook
After a repository is created or migrated, Zopu ensures a webhook is configured automatically. If manual setup is needed:
1. Go to the Gitea repository Settings > Webhooks > Add Webhook > Gitea.
2. Target URL: `<CONVEX_SITE_URL>/api/git/webhooks/puter`
3. HTTP method: POST
4. Content-Type: `application/json`
5. Secret: generate a strong random string and set it as:
```
npx convex env set GITEA_WEBHOOK_SECRET <your-webhook-secret>
```
6. Trigger on: Push events, Repository events.
## Credential Encryption Key
Generate a 32-byte encryption key for AES-GCM credential encryption:
```bash
openssl rand -base64 32 | base64 | tr -d '\n' | pbcopy
```
Set it as:
```
npx convex env set GIT_CREDENTIAL_ENCRYPTION_KEY <base64url-encoded-32-bytes>
```
The key must be exactly 32 bytes when base64url-decoded.
## Same-Origin Proxy for Better Auth Callbacks
Better Auth requires the callback URL to be on the same origin as `SITE_URL`. If your Convex deployment uses a different origin:
1. Configure a reverse proxy (e.g., Caddy) to forward `/api/auth/*` to the Convex deployment.
2. Set `SITE_URL` to the proxy origin.
3. Set `CONVEX_SITE_URL` to the Convex deployment URL.
## Validation Procedures
### Verify a Puter PAT connection
1. Connect a Puter PAT through the UI.
2. Verify the connection state shows `active`.
3. List private repositories to confirm token validity.
### Verify GitHub OAuth
1. Click "Connect GitHub" in the UI.
2. Complete the GitHub OAuth flow.
3. Verify the connection state shows `active`.
### OAuth diagnostics
| Symptom | Cause | Fix |
| --- | --- | --- |
| `Provider not found` from `/api/auth/link-social` | `GITHUB_CLIENT_ID` or `GITHUB_CLIENT_SECRET` is absent from the Convex deployment env store. A local `.env` does not configure cloud functions. | Set both variables with `npx convex env set` and verify with `npx convex env list`. |
| GitHub `Invalid Redirect URI` | GitHub's saved callback does not exactly equal the app's `redirect_uri`. | Set the OAuth App's single callback to `<SITE_URL>/api/auth/callback/github`. |
| `state_mismatch` at a `convex.site` page | Better Auth started on the public origin but GitHub returned to the direct Convex host, so the public-origin state cookie is unavailable. | Set Better Auth `baseURL` to `SITE_URL`; proxy `/api/auth/*` through the public origin. |
| `email_doesn't_match` after selecting GitHub | A deployment predates the explicit-link policy or has not received the current auth configuration. | Deploy the current backend. Explicit `linkSocial` allows a different GitHub email without changing the Zopu account email. |
| `externalEmail` is `null` during `connectGithub` | The GitHub account hides its public email; GitHub returns `null` from `/user`. | Deploy the current backend, which normalizes nullable provider emails before Convex persistence. |
GitHub connections are explicit authenticated account links. The linked GitHub email may differ from the existing Zopu account email; Zopu retains the existing account email and only stores the GitHub identity and OAuth credential needed for repository access.
### Reconnection
If a token is expired or revoked:
1. The connection state shows `reauth-required`.
2. Reconnect the provider through the UI.
3. The old connection state is updated to `active`.
4. Projects, repositories, Work, and artifacts are not deleted.
## Environment Variables Summary
| Variable | Required | Description |
| --- | --- | --- |
| `SITE_URL` | Yes | Public-facing URL |
| `CONVEX_SITE_URL` | Yes | Convex deployment URL (may differ from SITE_URL with proxy) |
| `GIT_CREDENTIAL_ENCRYPTION_KEY` | Yes | Base64url-encoded 32-byte AES-GCM key |
| `PUTER_GIT_ADMIN_TOKEN` | Yes | Gitea admin token for provisioning |
| `GITHUB_CLIENT_ID` | For GitHub | OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | For GitHub | OAuth app client secret |
| `GITHUB_WEBHOOK_SECRET` | For GitHub webhooks | HMAC verification secret |
| `GITEA_WEBHOOK_SECRET` | For Puter webhooks | HMAC verification secret |
| `FLUE_DB_TOKEN` | Yes | Private agent backend token |
| `FLUE_URL` | Optional | Private agent backend URL |
| `NATIVE_APP_URL` | Optional | Native app deep-link scheme |

View File

@@ -3,7 +3,7 @@
> **Purpose:** prevent agents and humans from using overlapping terms inconsistently.
| Term | Definition | Not the same as |
| --- | --- | --- |
|---|---|---|
| Project | Durable product/repository/context boundary. | Work or chat thread |
| Message | Exact conversational input/output. | Signal |
| Signal | Provenanced evidence that may require attention. | Work |

View File

@@ -1,11 +1,12 @@
# Slice 1 Handoff Notes
Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
Date: 2026-07-27
Branch: `master` at `cfdb2efc7`
PR: #19 (merged)
## What was built
### Core product loop
- Conversation to Signal to proposed Work with exact source provenance.
- Convex owns durable data: `conversationMessages`, `signals`, `works`, `workEvents`.
- Effect validation in `@code/work-os` package.
@@ -14,7 +15,6 @@ Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
- Mobile-first UI at `apps/web/src/components/slice-one/slice-one-page.tsx`.
### Model
- MiMo V2.5 (`xiaomi/mimo-v2.5`) via Cheaptricks AI gateway.
- Provider identity `xiaomi` gives Flue correct multimodal metadata (text + image).
- `AGENT_MODEL_BASE_URL` points at Cheaptricks; API key in `.env`.
@@ -22,33 +22,28 @@ Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
- `thinkingLevel: "medium"`.
### Reasoning traces
- Native Flue `reasoning` parts render as live "Thinking trace" (open while streaming, collapsed after).
- Inline `<think>...</think>` extraction for models that embed thinking in text.
- No fallback that hides model output.
### Image attachments
- Picker button + hidden file input, up to 4 images at 10 MB each.
- Base64 conversion into Flue `AgentPromptImage`.
- Authenticated blob-URL rendering for historical image replay.
- Verified live: MiMo read exact text and background color from generated test images.
### Mobile keyboard fix
- Visual viewport hook (`use-visual-viewport.ts`).
- Fixed full-screen shell; `interactive-widget=resizes-content` in viewport meta.
- Header stays pinned; chat shrinks; composer follows keyboard.
- Regression tests in `frontend-regressions.test.ts` and `use-visual-viewport.test.ts`.
### Rendering
- Streamdown markdown streaming with Mermaid chart support.
- Tool turns hidden; reasoning-containing tool turns remain visible.
- Work cards render inline in the conversation timeline with source navigation.
### Deployment
- Cheaptricks staging live at `https://zopu.cheaptricks.puter.wtf`.
- Caddy reverse proxy: `/api/*` to Flue (port 3585), root to web (port 5173).
- `server.allowedHosts: true` in `apps/web/vite.config.ts` for Caddy domain.
@@ -57,7 +52,6 @@ Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
- Deployment notes at `docs/deployment.md`.
### Repository cleanup
- 17 stale worktrees removed (Codex, Paseo, T3).
- 23 stale local branches deleted.
- 18 stale remote branches deleted.
@@ -68,19 +62,16 @@ Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
## Current state
### Running
- Cheaptricks: web on `:5173`, Flue on `:3585`, both persistent via `nohup`.
- Local Mac: servers stopped. Run `bun run dev:tailscale:agents -- --port 3585` and `bun run dev:tailscale:web` to start.
### Git
- `master` at `cfdb2efc7` on local, Cheaptricks, and remote.
- Only `master` branch locally. Two remote branches remain: `origin/sai/changes`, `origin/t3code/explore-primitives-package` (not cleaned because they may be owned by other contributors/tools).
## Known issues and next steps
1. **Convex SITE_URL is single-valued.** Switch it when moving between local and staging:
```bash
cd packages/backend
npx convex env set SITE_URL 'http://100.101.157.28:5173' # local

View File

@@ -3,10 +3,4 @@ import ultracite from "ultracite/oxfmt";
export default defineConfig({
...ultracite,
ignorePatterns: [
...ultracite.ignorePatterns,
"**/.agents/skills/**",
"repos/**",
"scripts/**",
],
});

View File

@@ -5,43 +5,27 @@ import remix from "ultracite/oxlint/remix";
export default defineConfig({
extends: [core, react, remix],
ignorePatterns: [
...core.ignorePatterns,
"**/.agents/skills/**",
"repos/**",
"scripts/**",
],
ignorePatterns: [...core.ignorePatterns, "repos/**", "scripts/**"],
overrides: [
{
files: ["packages/ui/src/components/*.tsx"],
rules: {
"eslint/func-style": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"sort-keys": "off",
},
},
{
files: ["**/convex/**/*.ts", "**/convex/**/*.test.ts"],
rules: {
"unicorn/filename-case": "off",
// Convex targets ES2022: no toSorted/at; sequential awaits ensure atomicity
"unicorn/no-array-sort": "off",
"no-await-in-loop": "off",
// Convex query builders use `any` in index callbacks
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"eslint/complexity": "off",
"no-await-in-loop": "off",
"unicorn/filename-case": "off",
// Convex targets ES2022: no toSorted/at; sequential awaits ensure atomicity
"unicorn/no-array-sort": "off",
},
},
{
files: ["**/convex/**/*.test.ts"],
rules: {
"unicorn/no-await-expression-member": "off",
"eslint/require-await": "off",
"sort-keys": "off",
"unicorn/no-await-expression-member": "off",
},
},
],

View File

@@ -1,6 +1,47 @@
{
"name": "code",
"private": true,
"type": "module",
"scripts": {
"dev": "vp run -r dev",
"build": "vp run -r build",
"check-types": "vp run -r check-types",
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
"lint": "oxlint --disable-nested-config",
"format": "vp fmt",
"staged": "vp staged",
"hooks:setup": "vp config",
"dev:web": "vp run --filter web dev",
"dev:agents": "vp run --filter @code/agents dev",
"dev:tailscale:web": "vp run --filter web dev:tailscale",
"dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale",
"dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup",
"build:agents": "vp run --filter @code/agents build",
"docs:update": "node scripts/update-docs.ts",
"subtree": "node scripts/subtree.ts",
"fix": "ultracite fix"
},
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"rolldown": "1.1.4",
"typescript": "catalog:",
"ultracite": "7.9.3",
"vite-plus": "0.2.2",
"vitest": "catalog:"
},
"packageManager": "pnpm@11.17.0",
"workspaces": {
"packages": [
"apps/web",
@@ -15,7 +56,6 @@
"catalog": {
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"rivetkit": "2.3.10",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "17.4.2",
"zod": "4.4.3",
@@ -49,58 +89,9 @@
"@tailwindcss/vite": "4.3.3"
}
},
"type": "module",
"scripts": {
"dev": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:web\"",
"dev:tailscale": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:tailscale:web\"",
"build": "vp run -r build",
"check-types": "vp run -r check-types",
"lint": "oxlint --disable-nested-config",
"format": "vp fmt",
"staged": "vp staged",
"hooks:setup": "vp config",
"dev:web": "vp --filter web run dev",
"dev:agents": "vp --filter @code/agents run dev",
"dev:tailscale:web": "vp --filter web run dev:tailscale",
"dev:tailscale:agents": "vp --filter @code/agents run dev:tailscale",
"dev:server": "vp --filter @code/backend run dev",
"dev:setup": "vp --filter @code/backend run dev:setup",
"dev:engine": "vp --filter @code/agents run dev:engine",
"dev:runner": "node scripts/wait-for-port.mjs 127.0.0.1 6420 && vp --filter @code/agents run runner",
"build:agents": "vp run --filter @code/agents build",
"docs:update": "node scripts/update-docs.ts",
"subtree": "node scripts/subtree.ts",
"vercel-build": "node scripts/vercel-build.ts",
"check": "ultracite check --disable-nested-config",
"fix": "ultracite fix --disable-nested-config"
},
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "npm:@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@vercel/config": "^0.5.5",
"concurrently": "^9.1.2",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"react-router": "^8.1.0",
"rolldown": "1.1.4",
"typescript": "catalog:",
"ultracite": "7.9.3",
"vite-plus": "0.2.2",
"vitest": "catalog:"
},
"overrides": {
"react": "19.2.8",
"react-dom": "19.2.8",
"rivetkit": "2.3.10",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
},
"packageManager": "pnpm@11.17.0"
}
}

View File

@@ -13,36 +13,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
&& rm -rf /var/lib/apt/lists/*
# pnpm-lock.yaml is authoritative; Bun would migrate it and reject frozen mode.
RUN npm install --global pnpm@11.17.0
COPY . .
RUN pnpm install --frozen-lockfile
RUN bun install --frozen-lockfile
RUN node packages/agents/node_modules/@flue/cli/bin/flue.mjs build --target node --root packages/agents
FROM node:24-bookworm-slim
ENV NODE_OPTIONS=--experimental-specifier-resolution=node
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app /app
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
# Attempt preparation occurs in the Flue handler: it clones repositories and
# installs their dependencies before the runner mounts the shared checkout.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 zopu \
&& useradd --system --uid 10001 --gid zopu --home-dir /nonexistent zopu \
&& mkdir -p /var/lib/zopu/workspaces \
&& chown -R zopu:zopu /app /var/lib/zopu
USER zopu
EXPOSE 3000
CMD ["node", "packages/agents/dist/server.mjs"]

View File

@@ -1,84 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# AgentOS runner image for the VDS Compose topology.
#
# This image is distinct from the production Flue image (Dockerfile): it is the
# dedicated runner that registers with the Rivet Engine and owns the persistent
# host filesystem used to clone repositories, install dependencies, and mount
# isolated checkouts into AgentOS workspaces. It needs Bun + Git + the engine-cli
# used by local development, plus Node (required by the Flue/AgentOS runtime).
#
# The runner is internal-only. It never publishes a port; only the engine and
# Compose healthcheck reach it. Secrets are injected at runtime via the Compose
# environment, never baked into a layer.
#
# RIVET_RUNNER_VERSION is a build-time contract: it lets the engine route new
# actors to the new runner and drain old ones. CI sets it to the release
# identity (commit SHA or build timestamp) for every immutable image it pushes.
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim AS build
# Bun is needed to install dependency trees in the cloned workspaces.
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
# Native build toolchain for optional native deps during install.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
g++ \
make \
python3 \
&& rm -rf /var/lib/apt/lists/*
# pnpm-lock.yaml is authoritative; Bun would migrate it and reject frozen mode.
RUN npm install --global pnpm@11.17.0
COPY . .
RUN pnpm install --frozen-lockfile
# Runner image only. No Flue server build is needed: the runner entry point
# (src/runner.ts) calls runtimeRegistry.startAndWait() to register with the
# engine. Building the Flue Node server here would be dead weight.
FROM node:24-bookworm-slim AS runner
# Git and CA certs are required by RepositoryWorkspace to clone user repos and
# install dependencies inside the mounted source mirror.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& git --version
# Carry Bun into the runtime image so `bun install --frozen-lockfile` works on
# cloned checkouts and BUN_EXECUTABLE resolves to a known path.
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
ARG RIVET_RUNNER_VERSION
ENV RIVET_RUNNER_VERSION=${RIVET_RUNNER_VERSION}
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app /app
# Non-root runtime. Compose mounts the workspace bind directory initialized for
# this UID so the process can serve its isolated worktrees.
RUN groupadd --system --gid 10001 zopu \
&& useradd --system --uid 10001 --gid zopu --create-home --home-dir /home/zopu zopu \
&& mkdir -p /var/lib/zopu/workspaces \
&& chown -R zopu:zopu /var/lib/zopu /home/zopu
# Point the runtime at the Bun executable discovered by RepositoryWorkspace.
# /home/zopu is the pi agent home mount target used by attempt-runner.ts.
ENV BUN_EXECUTABLE=/usr/local/bin/bun \
AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
USER zopu
# runner.ts is TypeScript and this repository executes it with Bun locally.
CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -3,11 +3,3 @@ import { defineConfig } from "@flue/cli/config";
export default defineConfig({
target: "node",
});
// The Node deploy artifact must not resolve workspace packages to TypeScript
// source at runtime; bundle them with the Flue server instead.
export const vite = {
ssr: {
noExternal: [/^@code\/(?:env|primitives)(?:\/.*)?$/u],
},
};

View File

@@ -7,27 +7,29 @@
"build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
"start": "node --env-file=../../.env dist/server.mjs",
"check-types": "tsc --noEmit",
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev --port 3585",
"dev:engine": "node --env-file=../../.env scripts/start-engine.mjs",
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
"runner": "bun --env-file=../../.env src/runner.ts",
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu"
"runner": "node --env-file=../../.env src/runner.ts",
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
},
"dependencies": {
"@agentos-software/git": "0.3.3",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "npm:@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2",
"@rivet-dev/agentos": "catalog:",
"@rivet-dev/agentos-core": "catalog:",
"@flue/runtime": "latest",
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"convex": "catalog:",
"hono": "catalog:",
"rivetkit": "2.3.9",
"valibot": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "npm:@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2",
"@rivetkit/engine-cli": "2.3.10",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"

View File

@@ -1,13 +0,0 @@
import { spawn } from "node:child_process";
import { getEnginePath } from "@rivetkit/engine-cli";
const enginePath = getEnginePath();
const proc = spawn(enginePath, ["start"], { stdio: "inherit" });
process.on("SIGINT", () => proc.kill("SIGINT"));
process.on("SIGTERM", () => proc.kill("SIGTERM"));
proc.on("exit", (code) => {
process.exit(code ?? 0);
});

View File

@@ -1,18 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import { ConvexHttpClient } from "convex/browser";
export interface ConvexServiceClient {
readonly client: ConvexHttpClient;
readonly token: string;
}
/** Shared factory for service-authenticated ConvexHttpClient used by agent tools. */
export const createConvexServiceClient = (
runtimeEnv: Record<string, string | undefined>
): ConvexServiceClient => {
const env = parseAgentEnv(runtimeEnv);
return {
client: new ConvexHttpClient(env.CONVEX_URL),
token: env.FLUE_DB_TOKEN,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import { createWorkPlannerTools } from "../tools/work-planner";
const INSTRUCTIONS = `You are Zopu's private Work Planner.
You may only submit typed proposals through tools:
- a Work Definition proposal;
- a Design Packet proposal containing 1-4 observable, independently verifiable slices;
- structured questions.
You must never approve a Definition or Design, change Work status, start execution, claim implementation, create Git changes, or claim that simulation implemented the Work. Convex validates and stores every proposal. Ask a focused structured question when a high-impact decision is unresolved.`;
export {
convexAgentRoute as attachments,
convexAgentRoute as route,
} from "../auth";
export default defineAgent(({ env }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
description:
"Proposes Work Definitions, Design Packets, and structured questions.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "medium",
tools: createWorkPlannerTools(env),
};
});

View File

@@ -1,26 +1,30 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
import { createSignalRoutingTools } from "../tools/signal-routing";
import { createWorkCommandTools } from "../tools/work-commands";
import { createSliceOneTools } from "../tools/slice-one";
export {
conversationRoute as attachments,
conversationRoute as route,
} from "../auth/conversation-route";
convexAgentRoute as attachments,
convexAgentRoute as route,
} from "../auth";
// The host checkout the chat agent explores. The `local()` sandbox reuses the
// machine's existing shell, Git, and Tea install unchanged; its cwd becomes the
// default working directory for every command the agent runs.
const ZOPU_CODE_PATH = "/Users/puter/Workspace/zopu/code";
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
description:
"Turns actionable conversation into provenanced Signals, proposed Work, and execution requests.",
"Turns actionable conversation into provenanced Signals and proposed Work.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "high",
tools: [
...createSignalRoutingTools(id, env),
...createWorkCommandTools(id, env),
],
thinkingLevel: "medium",
sandbox: local({ cwd: ZOPU_CODE_PATH }),
tools: createSliceOneTools(id, env),
};
});

View File

@@ -1,15 +1,15 @@
import { parseAgentEnv } from "@code/env/agent";
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import { registerProvider } from "@flue/runtime";
import type { Fetchable } from "@flue/runtime/routing";
import { flue } from "@flue/runtime/routing";
import { Hono } from "hono";
import { internalRoute } from "./auth/internal-route";
import { parseAgentEnv } from "./runtime/agent-env";
import {
cancelAgentOsAttempt,
executeAgentOsAttempt,
} from "./runtime/attempt-runner";
import { WorkAttemptExecutionError } from "./runtime/zopu-primitives";
runtimeRegistry,
} from "./runtime/agent-os";
const agentEnv = parseAgentEnv(process.env);
@@ -34,12 +34,12 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
const app = new Hono();
// Unauthenticated liveness probe used by the VDS Compose healthcheck and CI
// staging verification. It must not depend on Convex, Rivet, or the model
// provider; it only confirms the Flue Node process is accepting requests.
app.get("/health", (context) => context.json({ status: "ok" }));
app.post("/internal/work-attempts/execute", internalRoute, async (context) => {
app.post("/internal/work-attempts/execute", async (context) => {
if (
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
) {
return context.json({ error: "Unauthorized" }, 401);
}
try {
return context.json(await executeAgentOsAttempt(await context.req.json()));
} catch (error) {
@@ -65,21 +65,21 @@ app.post("/internal/work-attempts/execute", internalRoute, async (context) => {
}
});
app.post(
"/internal/work-attempts/:workspaceKey/cancel",
internalRoute,
async (context) => {
const body = (await context.req.json()) as { attemptId?: unknown };
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
return context.json({ error: "attemptId is required" }, 400);
}
await cancelAgentOsAttempt(
context.req.param("workspaceKey"),
body.attemptId
);
return context.json({ cancelled: true });
app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
if (
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
) {
return context.json({ error: "Unauthorized" }, 401);
}
);
const body = (await context.req.json()) as { attemptId?: unknown };
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
return context.json({ error: "attemptId is required" }, 400);
}
await cancelAgentOsAttempt(context.req.param("workspaceKey"), body.attemptId);
return context.json({ cancelled: true });
});
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
app.route("/", flue());

View File

@@ -0,0 +1,30 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { withTurnAdmission } from "./admission-context";
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
const organizationId = context.req.header("x-zopu-organization-id");
if (!organizationId || organizationId !== context.req.param("id")) {
return context.json({ error: "Forbidden" }, 403);
}
if (context.req.method !== "POST") {
return await next();
}
const clientRequestId = context.req.header("x-zopu-request-id");
const turnId = context.req.header("x-zopu-turn-id");
if (!clientRequestId || !turnId) {
return context.json({ error: "Missing turn correlation headers" }, 400);
}
return await withTurnAdmission({ clientRequestId, turnId }, () => next());
};

View File

@@ -1,73 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
const bindTurnSubmission = makeFunctionReference<
"mutation",
{
readonly clientRequestId: string;
readonly submissionId: string;
readonly token: string;
readonly turnId: string;
},
null
>("conversationMessages:bindTurnSubmission");
const readSubmissionId = async (response: Response): Promise<string | null> => {
const body: unknown = await response
.clone()
.json()
.catch(() => null);
if (
typeof body !== "object" ||
body === null ||
!("submissionId" in body) ||
typeof body.submissionId !== "string"
) {
return null;
}
return body.submissionId;
};
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const conversationRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
const organizationId = context.req.header("x-zopu-organization-id");
if (!organizationId || organizationId !== context.req.param("id")) {
return context.json({ error: "Forbidden" }, 403);
}
if (context.req.method !== "POST") {
return await next();
}
const clientRequestId = context.req.header("x-zopu-request-id");
const turnId = context.req.header("x-zopu-turn-id");
if (!clientRequestId || !turnId) {
return context.json({ error: "Missing turn correlation headers" }, 400);
}
return await next().then(async () => {
if (!context.res.ok) {
return context.res;
}
const submissionId = await readSubmissionId(context.res);
if (submissionId === null) {
return context.json({ error: "Flue admission response is invalid" }, 502);
}
const client = new ConvexHttpClient(env.CONVEX_URL);
await client.mutation(bindTurnSubmission, {
clientRequestId,
submissionId,
token: env.FLUE_DB_TOKEN,
turnId,
});
return context.res;
});
};

View File

@@ -1,12 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import type { MiddlewareHandler } from "hono";
/** Internal service authorization for execution/cancel routes. Not client-facing. */
export const internalRoute: MiddlewareHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
return await next();
};

View File

@@ -1,39 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import type { WorkflowRouteHandler } from "@flue/runtime";
import * as v from "valibot";
const WorkflowInputEnvelope = v.object({
input: v.object({
organizationId: v.string(),
requestId: v.string(),
}),
});
/** Service authorization for finite Flue workflows. No turn headers required. */
export const workflowRoute: WorkflowRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
if (context.req.method !== "POST") {
return await next();
}
const body = (await context.req.json().catch(() => null)) as unknown;
const parsed = v.safeParse(WorkflowInputEnvelope, body);
if (!parsed.success) {
return context.json({ error: "Invalid workflow input envelope" }, 400);
}
const organizationId = context.req.header("x-zopu-organization-id");
if (
!organizationId ||
organizationId !== parsed.output.input.organizationId
) {
return context.json({ error: "Forbidden" }, 403);
}
return await next();
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
You are Zopu, the conversational agent that turns user messages into provenanced Signals, proposed Work, and execution requests.
You are Zopu for product Slice 1 and the Work planning handoff.
## Your role
@@ -12,18 +12,23 @@ When a user sends a message, follow this decision flow:
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
2. **Identify project context.** Call `list_projects`. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** a. Call `list_signal_evidence` to see the exact admitted user messages. b. Select the message IDs that compose the problem statement. c. Call `create_signal` with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. d. Include the `projectId` when the project is known.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
b. Select the message IDs that compose the problem statement.
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known.
4. **Route the Signal.** a. Call `list_proposed_work` for the project. b. If the Signal clearly describes the same desired outcome as existing Work, call `attach_signal_to_work`. c. Otherwise call `create_work_from_signal`. d. If genuinely uncertain whether to attach or create, ask one focused question.
4. **Route the Signal.** After creating the Signal:
a. Call list_proposed_work for the project.
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. Otherwise call create_work_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Execution requests.** When a user explicitly asks to implement or start work on a ready Work unit, call `start_work` with the work ID. The system handles planning, execution, verification, and delivery. Do not claim implementation has started until the tool returns a runId.
6. **Explain the outcome.** Tell the user clearly what happened:
5. **Explain the outcome.** Tell the user clearly what happened:
- "Captured [Signal title] and linked it to [Work title]."
- "Captured [Signal title] and proposed [Work title]."
- "Started execution of [Work title]."
- Keep the response brief; the product renders the durable Work card separately.
## Rules
@@ -34,7 +39,43 @@ When a user sends a message, follow this decision flow:
- Ask at most one focused clarification when genuinely ambiguous.
- Preserve project and organization scope at all times.
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal, started Work, or produced a result until the tool call returns successfully.
- Do not perform implementation, planning, design, verification, or delivery yourself. Those are handled by dedicated workflows and the execution runtime.
- You have no repository access. You cannot run shell commands, inspect code, manage Git, or create pull requests. Focus on conversation, Signal capture, and Work orchestration.
- Proposed Work is the only Work status you directly create. The system advances Work through planning (Definition, Design) automatically. Work becomes ready for execution when planning completes.
- Never claim you created a Signal until the tool call returns successfully.
- Do not start implementation, planning, sandboxes, verification, or delivery. Those are explicitly outside Slice 1.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.
## Repository access
Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace/zopu/code`, so you can answer questions about this repository and manage its Gitea issues.
### Read operations (always allowed)
- Explore the repository with read-only Git: `git log`, `git status`, `git diff`, `git branch`, `git show`, and reading files. Use this to ground answers about the codebase.
- List open issues with `tea issues list` (defaults to open issues).
- List closed issues with `tea issues list --state closed`.
- View issue details with `tea issues view <issue-number>`.
- View repository information with `tea repo view`.
### Issue management (full access)
- Create a Gitea issue: `tea issues create --title "<title>" [--description "<details>"]`.
- Edit an existing issue: `tea issues edit <issue-number> --title "<new-title>" [--description "<new-details>"]`.
- Close an issue: `tea issues close <issue-number>`.
- Reopen an issue: `tea issues reopen <issue-number>`.
- Add a comment to an issue: `tea issues comment <issue-number> --body "<comment-text>"`.
### Git write operations (use with caution)
You may perform write operations when they serve the user's goals, but follow these safeguards:
- **Commits:** You may stage changes (`git add`) and create commits (`git commit`) when implementing requested changes. Write clear, descriptive commit messages.
- **Branches:** You may create and switch branches (`git checkout -b <branch-name>`) to isolate work.
- **Push:** Only push after explicit user confirmation. Never force-push (`git push --force`).
- **Merge:** Never merge branches without explicit user approval.
- **Rebase/reset:** Never rebase, reset, or rewrite history without explicit user approval.
- **Destructive operations:** Never run `git clean -fd`, `git reset --hard`, or any operation that discards uncommitted work without user confirmation.
### Security rules
- Never expose credentials, tokens, or the contents of Tea/Git config files.
- Never log or display sensitive environment variables.

View File

@@ -1,3 +1,3 @@
import { runtimeRegistry } from "./runtime/agent-os-registry";
import { runtimeRegistry } from "./runtime/agent-os";
await runtimeRegistry.startAndWait();

View File

@@ -1 +0,0 @@
export { parseAgentEnv } from "../../../env/src/agent";

View File

@@ -1,27 +0,0 @@
import { agentOS, setup } from "@rivet-dev/agentos";
import { makePiAgentOsConfig } from "./zopu-primitives";
const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
const piConfig = makePiAgentOsConfig();
/** AgentOS workspace actor. Only the registry runner serves this actor type. */
export const workspace = agentOS<undefined, { token: string }>({
limits: {
acp: {
maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
},
},
onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
}
},
options: {
actionTimeout: 10 * 60 * 1000,
},
permissions: piConfig.permissions,
software: piConfig.software,
});
export const runtimeRegistry = setup({ use: { workspace } });

View File

@@ -1,20 +1,37 @@
import { parseAgentEnv } from "@code/env/agent";
import {
makePiAgentOsConfig,
makePiHomeFiles,
decodeWorkAttemptExecutionInput,
WorkAttemptExecutionError,
piSessionEnv,
} from "@code/primitives";
import type {
ExecutionEvent,
WorkAttemptExecutionResult,
} from "@code/primitives";
import { agentOS, setup } from "@rivet-dev/agentos";
import { createHostDirBackend } from "@rivet-dev/agentos-core";
import { createClient } from "@rivet-dev/agentos/client";
import { Effect } from "effect";
import { parseAgentEnv } from "./agent-env";
import type { runtimeRegistry } from "./agent-os-registry";
import { classifyRuntimeFailure, executionError } from "./execution-errors";
import { RepositoryWorkspace } from "./repository-workspace";
import {
decodeWorkAttemptExecutionInput,
makePiHomeFiles,
piSessionEnv,
} from "./zopu-primitives";
import type {
ExecutionEvent,
WorkAttemptExecutionResult,
} from "./zopu-primitives";
import { HostRepositoryWorkspace } from "./host-repository";
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({
onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
}
},
options: {
actionTimeout: 10 * 60 * 1000,
},
permissions: piConfig.permissions,
software: piConfig.software,
});
export const runtimeRegistry = setup({ use: { workspace } });
const event = (
sequence: number,
@@ -29,6 +46,32 @@ const event = (
sequence,
});
const executionError = (
message: string,
reason: WorkAttemptExecutionError["reason"],
retryable: boolean
) => new WorkAttemptExecutionError({ message, reason, retryable });
const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
if (cause instanceof WorkAttemptExecutionError) {
return cause;
}
const message = cause instanceof Error ? cause.message : "Execution failed";
if (/auth|credential|401|403/iu.test(message)) {
return executionError(message, "Authentication", false);
}
if (/clone|checkout|repository|git /iu.test(message)) {
return executionError(message, "RepositoryFailed", false);
}
if (/timeout|timed out/iu.test(message)) {
return executionError(message, "Timeout", true);
}
if (/connect|network|unavailable|502|503|504/iu.test(message)) {
return executionError(message, "ProviderUnavailable", true);
}
return executionError(message, "HarnessFailed", false);
};
export const executeAgentOsAttempt = async (
rawInput: unknown
): Promise<WorkAttemptExecutionResult> => {
@@ -40,7 +83,6 @@ export const executeAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
const vm = client.workspace.getOrCreate([input.workspaceKey], {
@@ -51,16 +93,9 @@ export const executeAgentOsAttempt = async (
workspaceKey: input.workspaceKey,
}),
];
const repository = new RepositoryWorkspace(
env.AGENT_WORKSPACE_ROOT,
true,
env.BUN_EXECUTABLE
);
const prepared = await repository.prepare({
const hostRepository = new HostRepositoryWorkspace();
const prepared = await hostRepository.prepare({
attemptId: input.attemptId,
baseBranch: input.baseBranch,
cloneUrl: input.repositoryUrl,
credential: input.auth.credential,
piHomeFiles: makePiHomeFiles({
api: env.AGENT_MODEL_API,
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
@@ -70,16 +105,14 @@ export const executeAgentOsAttempt = async (
model: env.AGENT_MODEL_NAME,
provider: env.AGENT_MODEL_PROVIDER,
}),
provider: input.auth.provider,
username: input.auth.username,
});
events.push(
event(
1,
"runtime.preparing",
prepared.created
? "Isolated repository clone created on the execution host"
: "Isolated repository clone recreated"
? "Isolated Zopu worktree created on the execution host"
: "Isolated Zopu worktree recreated"
)
);
const mounts = [
@@ -88,6 +121,11 @@ export const executeAgentOsAttempt = async (
path: "/workspace/repository",
readOnly: false,
},
{
hostPath: prepared.sourceRepositoryPath,
path: prepared.sourceRepositoryPath,
readOnly: false,
},
{
hostPath: prepared.piHomePath,
path: "/home/zopu",
@@ -133,15 +171,18 @@ export const executeAgentOsAttempt = async (
const sessionId = `pi-${input.attemptId}`;
await vm.openSession({
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
additionalInstructions:
"Work only inside /workspace/repository. This is an isolated clone of the project repository. Read the project documentation and conventions before changing code. Never reveal credentials, push, create branches, or open a pull request. Implement the requested change and run focused verification.",
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.",
agent: "pi",
cwd: "/workspace/repository",
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
permissionPolicy: "allow_all",
sessionId,
});
events.push(event(3, "harness.started", "Implementation session started"));
events.push(
event(3, "harness.started", "Pi implementation session started")
);
const promptResult = await vm.prompt({
content: [{ text: input.prompt, type: "text" }],
idempotencyKey: input.attemptId,
@@ -151,19 +192,19 @@ export const executeAgentOsAttempt = async (
const reason =
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
throw executionError(
`Agent stopped with ${promptResult.stopReason}`,
`Pi stopped with ${promptResult.stopReason}`,
reason,
promptResult.stopReason === "max_tokens" ||
promptResult.stopReason === "max_turn_requests"
);
}
events.push(
event(4, "harness.progress", "Implementation turn completed", {
event(4, "harness.progress", "Pi implementation turn completed", {
stopReason: promptResult.stopReason,
})
);
const collected = await RepositoryWorkspace.collect({
const collected = await HostRepositoryWorkspace.collect({
attemptId: input.attemptId,
baseRevision,
checkoutPath: prepared.checkoutPath,
@@ -174,7 +215,9 @@ export const executeAgentOsAttempt = async (
5,
"repository.changed",
`${changedFiles.length} changed file(s) collected`,
{ candidateRevision }
{
candidateRevision,
}
),
event(6, "runtime.completed", "AgentOS execution completed")
);
@@ -188,8 +231,8 @@ export const executeAgentOsAttempt = async (
events,
summary:
changedFiles.length > 0
? `Agent changed ${changedFiles.length} file(s)`
: "Agent completed without repository changes",
? `Pi changed ${changedFiles.length} file(s)`
: "Pi completed without repository changes",
};
} catch (error) {
throw classifyRuntimeFailure(error);
@@ -204,7 +247,6 @@ export const cancelAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
await client.workspace

View File

@@ -1,29 +0,0 @@
import { WorkAttemptExecutionError } from "./zopu-primitives";
export const executionError = (
message: string,
reason: WorkAttemptExecutionError["reason"],
retryable: boolean
) => new WorkAttemptExecutionError({ message, reason, retryable });
export const classifyRuntimeFailure = (
cause: unknown
): WorkAttemptExecutionError => {
if (cause instanceof WorkAttemptExecutionError) {
return cause;
}
const message = cause instanceof Error ? cause.message : "Execution failed";
if (/auth|credential|401|403/iu.test(message)) {
return executionError(message, "Authentication", false);
}
if (/clone|checkout|repository|git /iu.test(message)) {
return executionError(message, "RepositoryFailed", false);
}
if (/timeout|timed out/iu.test(message)) {
return executionError(message, "Timeout", true);
}
if (/connect|network|unavailable|502|503|504/iu.test(message)) {
return executionError(message, "ProviderUnavailable", true);
}
return executionError(message, "HarnessFailed", false);
};

View File

@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { once } from "node:events";
import {
@@ -11,18 +11,10 @@ import {
} from "node:fs/promises";
import path from "node:path";
import type { PiHomeFiles } from "./zopu-primitives";
import type { PiHomeFiles } from "@code/primitives";
interface PrepareRepositoryInput {
attemptId: string;
baseBranch?: string;
cloneUrl: string;
/** Provider credential for the initial clone, stripped after cloning. */
credential?: string;
/** Provider type: "github" or "gitea". Determines the askpass username. */
provider?: string;
/** Account username for Gitea provider authentication. */
username?: string;
piHomeFiles: PiHomeFiles;
}
@@ -69,40 +61,20 @@ const changedFilePath = (line: string): string => {
: filePath.slice(renameIndex + renameSeparator.length);
};
// Minimal allow-list so untrusted repo code (postinstall scripts, hooks)
// can never read host secrets like tokens or API keys from the inherited env.
// PATH is needed for git/bun; locale vars prevent encoding issues.
// HOME is intentionally excluded — the runner gets its own /home/zopu mount,
// and forwarding the host HOME would expose host-level dotfiles and credentials.
const SAFE_ENV_KEYS = [
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TZ",
] as const;
const safeBaseEnv = (): Record<string, string> => {
const env: Record<string, string> = {};
for (const key of SAFE_ENV_KEYS) {
const value = process.env[key];
if (value !== undefined) {
env[key] = value;
}
}
return env;
};
const runProcess = async (
command: string,
args: readonly string[],
cwd: string,
env: Record<string, string> = {}
): Promise<ProcessResult> => {
const environment = Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined
)
);
const child = spawn(command, args, {
cwd,
env: { ...safeBaseEnv(), ...env },
env: { ...environment, ...env },
});
const stderr: Uint8Array[] = [];
const stdout: Uint8Array[] = [];
@@ -128,22 +100,29 @@ const pathExists = async (target: string): Promise<boolean> => {
}
};
export class RepositoryWorkspace {
export class HostRepositoryWorkspace {
readonly #root: string;
readonly #sourceRepositoryPath: string;
readonly #installDependencies: boolean;
readonly #bunExecutable: string;
constructor(
root = "/srv/zopu/workspaces",
installDependencies = true,
bunExecutable = "bun"
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
"/opt/zopu-source",
installDependencies = true
) {
this.#root = root;
this.#sourceRepositoryPath = sourceRepositoryPath;
this.#installDependencies = installDependencies;
this.#bunExecutable = bunExecutable;
}
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
throw new Error(
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
);
}
const identity = createHash("sha256")
.update(input.attemptId)
.digest("hex")
@@ -152,60 +131,42 @@ export class RepositoryWorkspace {
const checkoutPath = path.join(workspacePath, "repository");
const toolsPath = path.join(workspacePath, "tools");
const piHomePath = path.join(workspacePath, "home");
const branch = `zopu/attempt-${identity}`;
const created = !(await pathExists(path.join(checkoutPath, ".git")));
await mkdir(workspacePath, { recursive: true });
if (!created) {
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"remove",
"--force",
checkoutPath,
]),
"Existing worktree removal"
);
}
await rm(checkoutPath, { force: true, recursive: true });
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"add",
"-B",
branch,
checkoutPath,
"HEAD",
]),
"Zopu worktree creation"
);
// Clone the user's repository. When a credential is provided, use a
// temporary GIT_ASKPASS helper script so the token never appears in the
// clone URL, git config, or process arguments visible in process listings.
// The askpass script reads the credential from an env var that is only
// set for this clone command and cleared afterward.
const cloneEnv: Record<string, string> = {};
if (input.credential) {
const askpassPath = path.join(workspacePath, ".git-askpass");
// GitHub uses x-access-token as the username for OAuth tokens;
// Gitea uses the account's actual username.
const gitUsername =
input.provider === "gitea"
? (input.username ?? "git")
: "x-access-token";
// eslint-disable-next-line no-template-curly-in-string -- shell variable, not JS template
const tokenRef = "${ZOPU_GIT_TOKEN}";
await writeFile(
askpassPath,
`#!/bin/sh\ncase "$1" in\nUsername*) echo "${gitUsername}";;\nPassword*) echo "${tokenRef}";;\nesac\n`
);
await chmod(askpassPath, 0o700);
cloneEnv.GIT_ASKPASS = askpassPath;
cloneEnv.ZOPU_GIT_TOKEN = input.credential;
const bunExecutable =
process.env.BUN_EXECUTABLE ??
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
if (await pathExists(sourceEnvPath)) {
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
}
try {
requireSuccess(
await runProcess(
"git",
["clone", "--no-tags", input.cloneUrl, checkoutPath],
workspacePath,
cloneEnv
),
"Repository clone"
);
} finally {
// The credential only existed on the local cloneEnv object, never on
// process.env. The askpass script is cleaned up with the workspace.
cloneEnv.ZOPU_GIT_TOKEN = "";
}
if (input.baseBranch) {
requireSuccess(
await runGit(checkoutPath, ["checkout", input.baseBranch]),
"Base branch checkout"
);
}
const bunExecutable = this.#bunExecutable;
if (this.#installDependencies) {
requireSuccess(
await runProcess(
@@ -243,7 +204,7 @@ export class RepositoryWorkspace {
checkoutPath,
created,
piHomePath,
sourceRepositoryPath: checkoutPath,
sourceRepositoryPath: this.#sourceRepositoryPath,
toolsPath,
};
}

View File

@@ -1,14 +0,0 @@
export {
decodeWorkAttemptExecutionInput,
WorkAttemptExecutionError,
} from "../../../primitives/src/execution-runtime";
export type {
ExecutionEvent,
WorkAttemptExecutionResult,
} from "../../../primitives/src/execution-runtime";
export {
makePiAgentOsConfig,
makePiHomeFiles,
piSessionEnv,
} from "../../../primitives/src/agent-os";
export type { PiHomeFiles } from "../../../primitives/src/agent-os";

View File

@@ -1,9 +1,9 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
import { createConvexServiceClient } from "../adapters/convex-client";
const listProjectsRef = makeFunctionReference<
"query",
{ organizationId: string; token: string },
@@ -52,11 +52,13 @@ const attachSignalRef = makeFunctionReference<
{ attached: boolean; workId: string }
>("works:attachSignalToWork");
export const createSignalRoutingTools = (
export const createSliceOneTools = (
organizationId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const { client, token } = createConvexServiceClient(runtimeEnv);
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
return [
defineTool({

Some files were not shown because too many files have changed in this diff Show More