Compare commits
77 Commits
t3code/rev
...
ax
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4181a1271 | ||
|
|
3fc80154e8 | ||
|
|
b02200b3dc | ||
|
|
48205a5e19 | ||
|
|
289c16f11e | ||
|
|
f5b65a888e | ||
|
|
3869304718 | ||
|
|
52b90825a1 | ||
|
|
6364c33499 | ||
|
|
db9afd7a24 | ||
|
|
ce16813fbc | ||
|
|
95c23db4fc | ||
|
|
08bc5ae259 | ||
|
|
4dc878b8cb | ||
|
|
3e3f06403f | ||
|
|
8bd3953440 | ||
|
|
bb4bd9e0d3 | ||
|
|
f208d4e4b8 | ||
|
|
fc1d0b6916 | ||
|
|
fc8cfabacb | ||
|
|
ce1f33aa69 | ||
|
|
c644ec8d01 | ||
|
|
88f53f550d | ||
|
|
54a5993274 | ||
|
|
def3fc949a | ||
|
|
9f7dd3c1a6 | ||
|
|
a8b2ff5e2e | ||
|
|
40e0f7e1eb | ||
|
|
bf2300a6be | ||
|
|
0657037c84 | ||
|
|
64a783b445 | ||
|
|
ed28943e7a | ||
|
|
9e148489f0 | ||
|
|
25f86d94cc | ||
|
|
7668fa69cc | ||
|
|
3ffa1cfc7c | ||
|
|
18eb150d7d | ||
|
|
e1b0b731e0 | ||
|
|
d428a2492b | ||
|
|
fe0fd9b16c | ||
|
|
fc1fcf5d44 | ||
|
|
3ae72864bd | ||
|
|
0d5d54caa8 | ||
|
|
830bcc4756 | ||
|
|
0e56a462cd | ||
|
|
9fb293a539 | ||
|
|
062c00f53c | ||
|
|
a7e70c9b2a | ||
|
|
526ed59776 | ||
|
|
fd3980c6bf | ||
|
|
d8a4bbe804 | ||
|
|
9eb6bcd25f | ||
|
|
a907539810 | ||
|
|
601aca73c2 | ||
|
|
ffecff3857 | ||
|
|
0d7162544b | ||
|
|
092a9793ea | ||
|
|
5ee0a8d50e | ||
|
|
420676f2d7 | ||
|
|
24d82e2a06 | ||
|
|
d47fa0e96a | ||
|
|
1e7c893985 | ||
|
|
f9ebcb4a01 | ||
|
|
dceaa2b417 | ||
|
|
a4f121e190 | ||
|
|
4edd456b5b | ||
|
|
4d9f6da41b | ||
|
|
a53029cf7a | ||
|
|
eede4c10ba | ||
|
|
35169672e1 | ||
|
|
359d9e2285 | ||
|
|
05a3baaac3 | ||
|
|
4cc40cb2e4 | ||
|
|
814df02be9 | ||
|
|
d7f6cbdcdc | ||
|
|
a8d946b6a9 | ||
| 0e32c35515 |
43
.agents/skills/ax-agent-context/SKILL.md
Normal file
43
.agents/skills/ax-agent-context/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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.
|
||||
443
.agents/skills/ax-agent-memory-skills/SKILL.md
Normal file
443
.agents/skills/ax-agent-memory-skills/SKILL.md
Normal file
@@ -0,0 +1,443 @@
|
||||
---
|
||||
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).
|
||||
398
.agents/skills/ax-agent-observability/SKILL.md
Normal file
398
.agents/skills/ax-agent-observability/SKILL.md
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
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.
|
||||
368
.agents/skills/ax-agent-optimize/SKILL.md
Normal file
368
.agents/skills/ax-agent-optimize/SKILL.md
Normal file
@@ -0,0 +1,368 @@
|
||||
---
|
||||
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.
|
||||
501
.agents/skills/ax-agent-rlm/SKILL.md
Normal file
501
.agents/skills/ax-agent-rlm/SKILL.md
Normal file
@@ -0,0 +1,501 @@
|
||||
---
|
||||
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.
|
||||
693
.agents/skills/ax-agent/SKILL.md
Normal file
693
.agents/skills/ax-agent/SKILL.md
Normal file
@@ -0,0 +1,693 @@
|
||||
---
|
||||
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`.
|
||||
497
.agents/skills/ax-ai/SKILL.md
Normal file
497
.agents/skills/ax-ai/SKILL.md
Normal file
@@ -0,0 +1,497 @@
|
||||
---
|
||||
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.
|
||||
373
.agents/skills/ax-audio/SKILL.md
Normal file
373
.agents/skills/ax-audio/SKILL.md
Normal file
@@ -0,0 +1,373 @@
|
||||
---
|
||||
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.
|
||||
145
.agents/skills/ax-event-runtime/SKILL.md
Normal file
145
.agents/skills/ax-event-runtime/SKILL.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
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.
|
||||
682
.agents/skills/ax-flow/SKILL.md
Normal file
682
.agents/skills/ax-flow/SKILL.md
Normal file
@@ -0,0 +1,682 @@
|
||||
---
|
||||
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()`.
|
||||
543
.agents/skills/ax-gen/SKILL.md
Normal file
543
.agents/skills/ax-gen/SKILL.md
Normal file
@@ -0,0 +1,543 @@
|
||||
---
|
||||
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.
|
||||
264
.agents/skills/ax-gepa/SKILL.md
Normal file
264
.agents/skills/ax-gepa/SKILL.md
Normal file
@@ -0,0 +1,264 @@
|
||||
---
|
||||
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`
|
||||
351
.agents/skills/ax-llm/SKILL.md
Normal file
351
.agents/skills/ax-llm/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
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
|
||||
425
.agents/skills/ax-mcp/SKILL.md
Normal file
425
.agents/skills/ax-mcp/SKILL.md
Normal file
@@ -0,0 +1,425 @@
|
||||
---
|
||||
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`.
|
||||
125
.agents/skills/ax-playbook/SKILL.md
Normal file
125
.agents/skills/ax-playbook/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
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.
|
||||
81
.agents/skills/ax-refine/SKILL.md
Normal file
81
.agents/skills/ax-refine/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
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.
|
||||
421
.agents/skills/ax-signature/SKILL.md
Normal file
421
.agents/skills/ax-signature/SKILL.md
Normal file
@@ -0,0 +1,421 @@
|
||||
---
|
||||
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
|
||||
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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
|
||||
18
.env.example
18
.env.example
@@ -11,17 +11,19 @@ VITE_CONVEX_URL=https://example.convex.cloud
|
||||
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
|
||||
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
|
||||
|
||||
# Daemon identity and control plane
|
||||
DAEMON_ID=local-macbook
|
||||
DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
# RIVET_ENDPOINT=http://localhost:6420
|
||||
# AgentOS / Rivet runtime
|
||||
# The native Mode A envoy connects outbound to this private Engine control plane.
|
||||
# Convex calls authenticated Hono endpoints; it never connects to the Engine.
|
||||
RIVET_ENDPOINT=http://localhost:6420
|
||||
RIVETKIT_RUNTIME_MODE=envoy
|
||||
RIVETKIT_RUNTIME=native
|
||||
RIVET_ENVOY_VERSION=1
|
||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
||||
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
|
||||
|
||||
# Flue persistence adapter
|
||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
||||
FLUE_URL=http://localhost:3583
|
||||
FLUE_URL=http://127.0.0.1:3585
|
||||
|
||||
# Agent model provider
|
||||
AGENT_MODEL_PROVIDER=xiaomi
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -54,3 +54,10 @@ coverage
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
.env.*
|
||||
.env*
|
||||
infra
|
||||
|
||||
# Local issue-tracker drafts and agent memory state.
|
||||
.scratch/
|
||||
banks/
|
||||
19
.vercelignore
Normal file
19
.vercelignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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/
|
||||
banks/
|
||||
.scratch/
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||
"files.exclude": {
|
||||
"repos/**": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"repos/**": true
|
||||
},
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -1 +1,15 @@
|
||||
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/` (archived reference code — dormant apps, superseded web components, and prior implementations kept for reference), and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 1–2 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Hybrid: local markdown drafts in `.scratch/`, final polished issues published to Gitea. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Five canonical roles using default label strings. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`.
|
||||
|
||||
36
apps/web/Dockerfile
Normal file
36
apps/web/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
ARG VITE_AUTH_URL
|
||||
ARG VITE_CONVEX_URL
|
||||
ENV VITE_AUTH_URL=$VITE_AUTH_URL
|
||||
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun run --filter web build
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "node_modules/.bin/react-router-serve", "build/server/index.js"]
|
||||
@@ -25,16 +25,16 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "2.5.0"
|
||||
"sonner": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@react-router/dev": "^8.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@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:",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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;
|
||||
|
||||
145
apps/web/src/components/projects/add-project-panel.tsx
Normal file
145
apps/web/src/components/projects/add-project-panel.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
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 { useAction } from "convex/react";
|
||||
import { Globe2, LoaderCircle, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } 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 importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string>();
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const importPublicRepository = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const url = repositoryUrl.trim();
|
||||
if (!url || importing) {
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setImportError(undefined);
|
||||
try {
|
||||
const project = await importPublicGit({ repositoryUrl: url });
|
||||
setRepositoryUrl("");
|
||||
onProjectCreated(String(project.id));
|
||||
} catch (caughtError) {
|
||||
setImportError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Public Git import failed"
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<form
|
||||
className="rounded border border-[#d7d3c7] bg-[#fffefa] p-4"
|
||||
onSubmit={importPublicRepository}
|
||||
>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm font-semibold text-[#20201d]"
|
||||
htmlFor="public-git-url"
|
||||
>
|
||||
<Globe2 className="size-4 text-[#747168]" />
|
||||
Public Git URL
|
||||
</label>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="h-10 min-w-0 flex-1 border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none placeholder:text-[#858277] focus:border-[#55564e]"
|
||||
disabled={importing}
|
||||
id="public-git-url"
|
||||
onChange={(event) => setRepositoryUrl(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
type="url"
|
||||
value={repositoryUrl}
|
||||
/>
|
||||
<Button
|
||||
className="h-10 shrink-0"
|
||||
disabled={importing}
|
||||
type="submit"
|
||||
>
|
||||
{importing ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Globe2 className="size-4" />
|
||||
)}
|
||||
{importing ? "Importing" : "Import public Git"}
|
||||
</Button>
|
||||
</div>
|
||||
{importError ? (
|
||||
<p className="mt-2 text-xs leading-5 text-red-700">
|
||||
{importError}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<ProviderChips accounts={accounts} />
|
||||
|
||||
<RepositorySelector
|
||||
existingSourceUrls={existingSourceUrls}
|
||||
githubConnectionId={githubConnectionId}
|
||||
onProjectCreated={onProjectCreated}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
70
apps/web/src/components/projects/context-editor.tsx
Normal file
70
apps/web/src/components/projects/context-editor.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
36
apps/web/src/components/projects/github-connect-button.tsx
Normal file
36
apps/web/src/components/projects/github-connect-button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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}/?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>
|
||||
);
|
||||
};
|
||||
189
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
189
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
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}/?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>
|
||||
);
|
||||
};
|
||||
7
apps/web/src/components/projects/loading-state.tsx
Normal file
7
apps/web/src/components/projects/loading-state.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
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>
|
||||
);
|
||||
76
apps/web/src/components/projects/project-card.tsx
Normal file
76
apps/web/src/components/projects/project-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
100
apps/web/src/components/projects/project-setup-panel.tsx
Normal file
100
apps/web/src/components/projects/project-setup-panel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { ArrowRight, LoaderCircle, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useProjectSetup } from "@/hooks/use-project-setup";
|
||||
|
||||
import { ProjectSetupStatus } from "./project-setup-status";
|
||||
|
||||
export interface ProjectSetupPanelProps {
|
||||
readonly onClose: () => void;
|
||||
readonly onEnterProject: () => void;
|
||||
readonly projectId: Id<"projects">;
|
||||
readonly projectName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal surface shown after a project is created. Subscribes to the project
|
||||
* setup lifecycle and renders thin progress states. On "ready" it offers a CTA
|
||||
* to the global timeline / project home. Never surfaces raw runtime logs.
|
||||
*/
|
||||
export const ProjectSetupPanel = ({
|
||||
onClose,
|
||||
onEnterProject,
|
||||
projectId,
|
||||
projectName,
|
||||
}: ProjectSetupPanelProps) => {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const setup = useProjectSetup(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
dialog?.showModal();
|
||||
return () => dialog?.close();
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<dialog
|
||||
aria-labelledby="project-setup-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-lg flex-col overflow-hidden rounded bg-[#faf9f4] text-[#20201d] shadow-2xl sm:h-[min(40rem,calc(100svh-3rem))]">
|
||||
<div className="flex shrink-0 items-center justify-between gap-5 border-b border-[#d7d3c7] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] tracking-[0.16em] text-[#858277] uppercase">
|
||||
Project setup
|
||||
</p>
|
||||
<h2
|
||||
className="truncate text-base font-semibold tracking-tight text-[#20201d]"
|
||||
id="project-setup-heading"
|
||||
>
|
||||
{projectName}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close project setup"
|
||||
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-4 overflow-y-auto p-5">
|
||||
{setup.kind === "loading" ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
|
||||
</div>
|
||||
) : (
|
||||
<ProjectSetupStatus
|
||||
onRetry={setup.kind === "ready" ? setup.retry : undefined}
|
||||
phase={setup.phase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{setup.kind === "ready" && setup.phase === "ready" ? (
|
||||
<div className="mt-auto border-t border-[#e4e0d4] pt-4">
|
||||
<Button
|
||||
className="w-full bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
onClick={onEnterProject}
|
||||
type="button"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
Go to project
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</dialog>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
157
apps/web/src/components/projects/project-setup-status.tsx
Normal file
157
apps/web/src/components/projects/project-setup-status.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FolderGit2,
|
||||
LoaderCircle,
|
||||
PackageOpen,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ProjectSetupPhase } from "@/hooks/use-project-setup";
|
||||
|
||||
interface PhaseConfig {
|
||||
readonly description: string;
|
||||
readonly icon: typeof FolderGit2;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
const PHASE_CONFIG: Record<ProjectSetupPhase, PhaseConfig> = {
|
||||
blocked: {
|
||||
description: "Setup could not complete.",
|
||||
icon: XCircle,
|
||||
label: "Setup blocked",
|
||||
},
|
||||
checking_repository: {
|
||||
description: "Verifying the repository is readable.",
|
||||
icon: ShieldCheck,
|
||||
label: "Checking repository",
|
||||
},
|
||||
cloning_repository: {
|
||||
description: "Cloning the repository into the workspace.",
|
||||
icon: PackageOpen,
|
||||
label: "Cloning repository",
|
||||
},
|
||||
creating_workspace: {
|
||||
description: "Spinning up an isolated workspace for this project.",
|
||||
icon: FolderGit2,
|
||||
label: "Creating workspace",
|
||||
},
|
||||
ready: {
|
||||
description: "Project is set up and ready to explore.",
|
||||
icon: CheckCircle2,
|
||||
label: "Ready",
|
||||
},
|
||||
};
|
||||
|
||||
const ORDER: readonly ProjectSetupPhase[] = [
|
||||
"creating_workspace",
|
||||
"cloning_repository",
|
||||
"checking_repository",
|
||||
"ready",
|
||||
];
|
||||
|
||||
export interface ProjectSetupStatusProps {
|
||||
readonly onRetry?: () => void;
|
||||
readonly phase: ProjectSetupPhase;
|
||||
}
|
||||
|
||||
const resolveStepClassName = (
|
||||
isComplete: boolean,
|
||||
isActive: boolean
|
||||
): string => {
|
||||
if (isComplete) {
|
||||
return "grid size-7 shrink-0 place-items-center rounded-full bg-[#20201d] text-[#fffefa]";
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return "grid size-7 shrink-0 place-items-center rounded-full border border-[#20201d] text-[#20201d]";
|
||||
}
|
||||
|
||||
return "grid size-7 shrink-0 place-items-center rounded-full border border-[#c9c5b9] text-[#858277]";
|
||||
};
|
||||
|
||||
/**
|
||||
* Presentational rendering of the project setup lifecycle. Renders thin,
|
||||
* user-meaningful states with no raw runtime logs. The `blocked` phase renders
|
||||
* an actionable retry control when a handler is supplied.
|
||||
*/
|
||||
export const ProjectSetupStatus = ({
|
||||
onRetry,
|
||||
phase,
|
||||
}: ProjectSetupStatusProps) => {
|
||||
if (phase === "blocked") {
|
||||
return (
|
||||
<div className="flex items-start gap-3 border border-red-300 bg-red-50 p-4">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-red-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-red-800">
|
||||
Setup hit a problem
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-red-700">
|
||||
We could not finish preparing this project. You can retry now or
|
||||
close and try again later.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
className="mt-3 h-8 border-red-300 bg-white text-xs text-red-700 hover:bg-red-100"
|
||||
onClick={onRetry}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Retry setup
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeIndex = ORDER.indexOf(phase);
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-1">
|
||||
{ORDER.map((stepPhase, index) => {
|
||||
const config = PHASE_CONFIG[stepPhase];
|
||||
const isComplete = activeIndex > index;
|
||||
const isActive = stepPhase === phase;
|
||||
const Icon = isComplete ? CheckCircle2 : config.icon;
|
||||
const stepClassName = resolveStepClassName(isComplete, isActive);
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
className="flex items-center gap-3 px-1 py-2"
|
||||
key={stepPhase}
|
||||
>
|
||||
<span className={stepClassName}>
|
||||
{isActive ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={
|
||||
isActive || isComplete
|
||||
? "text-sm font-medium text-[#20201d]"
|
||||
: "text-sm font-medium text-[#858277]"
|
||||
}
|
||||
>
|
||||
{config.label}
|
||||
</p>
|
||||
{isActive ? (
|
||||
<p className="mt-0.5 text-xs leading-5 text-[#68665e]">
|
||||
{config.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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]">
|
||||
Open a project to manage its repository context.
|
||||
</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>
|
||||
);
|
||||
33
apps/web/src/components/projects/projects-header.tsx
Normal file
33
apps/web/src/components/projects/projects-header.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { FolderGit2, Plus } from "lucide-react";
|
||||
|
||||
export const ProjectsHeader = ({
|
||||
onOpenAddProject,
|
||||
}: {
|
||||
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
|
||||
</p>
|
||||
<h1 className="mt-0.5 text-2xl font-semibold tracking-tight text-[#20201d]">
|
||||
Projects
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
onClick={onOpenAddProject}
|
||||
size="lg"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add project
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
166
apps/web/src/components/projects/provider-chips.tsx
Normal file
166
apps/web/src/components/projects/provider-chips.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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}/?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>
|
||||
);
|
||||
};
|
||||
46
apps/web/src/components/projects/provider-section.tsx
Normal file
46
apps/web/src/components/projects/provider-section.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
80
apps/web/src/components/projects/puter-connect-form.tsx
Normal file
80
apps/web/src/components/projects/puter-connect-form.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
337
apps/web/src/components/projects/repository-selector.tsx
Normal file
337
apps/web/src/components/projects/repository-selector.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
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 {
|
||||
LoaderCircle,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
Lock,
|
||||
Search,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,657 +0,0 @@
|
||||
import { projectWorkNotices } from "@code/primitives/work";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
ChevronRight,
|
||||
Check,
|
||||
FolderGit2,
|
||||
Hammer,
|
||||
LoaderCircle,
|
||||
Menu,
|
||||
MessageSquareText,
|
||||
ImagePlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
||||
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
||||
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
|
||||
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
|
||||
import {
|
||||
buildSliceOneTimeline,
|
||||
findSourceMessageTarget,
|
||||
} from "@/lib/slice-one/presentation";
|
||||
|
||||
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
|
||||
const EMPTY_WORKS: readonly SliceWork[] = [];
|
||||
|
||||
interface WorkCardProps {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly work: SliceWork;
|
||||
readonly slice: SliceOneState;
|
||||
}
|
||||
|
||||
const starterDefinition = (work: SliceWork) => ({
|
||||
acceptanceCriteria: ["The requested outcome is observable and documented"],
|
||||
affectedUsers: ["Project users"],
|
||||
assumptions: [],
|
||||
constraints: [],
|
||||
desiredOutcome: work.objective,
|
||||
inScope: [work.objective],
|
||||
outOfScope: ["Unrelated product changes"],
|
||||
problem: work.objective,
|
||||
questions: [],
|
||||
requiredArtifacts: ["Simulation activity and terminal outcome"],
|
||||
risk: "medium",
|
||||
});
|
||||
|
||||
const starterDesign = (work: SliceWork) => ({
|
||||
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: [],
|
||||
impactMap: {
|
||||
files: [],
|
||||
modules: [],
|
||||
risks: [],
|
||||
summary: "Compact vertical-slice simulation",
|
||||
},
|
||||
invariants: [
|
||||
"Simulation never claims implementation",
|
||||
"Every Attempt reaches a terminal classification",
|
||||
],
|
||||
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
|
||||
slices: [
|
||||
{
|
||||
codeBoundaries: ["workExecution"],
|
||||
dependsOn: [],
|
||||
evidenceRequirements: ["Normalized activity events"],
|
||||
id: "slice-1",
|
||||
objective: work.objective,
|
||||
observableBehavior: "A terminal fake Run is visible",
|
||||
reviewRequired: false,
|
||||
title: "Deterministic simulation",
|
||||
verification: ["Run completes with a terminal classification"],
|
||||
},
|
||||
],
|
||||
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line complexity -- the expanded card intentionally keeps the three review sections together.
|
||||
const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const sources = work.signals.flatMap((signal) => signal.sources);
|
||||
const { definition } = work;
|
||||
const { design } = work;
|
||||
const [latestRun] = work.runs;
|
||||
return (
|
||||
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Proposed Work
|
||||
</p>
|
||||
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||
{work.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
||||
{work.objective}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
||||
onClick={() => setSourcesOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{sources.length} exact source{" "}
|
||||
{sources.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{sourcesOpen ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
||||
key={source.messageId}
|
||||
onClick={() => onSourceSelect(source.rawText)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
|
||||
<span className="min-w-0 flex-1">{source.rawText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Outcome
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
|
||||
<p className="mt-2 text-[#747168]">
|
||||
Risk: {definition?.risk ?? "not defined"}
|
||||
</p>
|
||||
{definition?.questions?.length ? (
|
||||
<p className="mt-1 text-amber-800">
|
||||
{
|
||||
definition.questions.filter(
|
||||
(question) => question.status === "open"
|
||||
).length
|
||||
}{" "}
|
||||
open question(s)
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "proposed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void slice.requestDefinition(work._id)}
|
||||
>
|
||||
<Sparkles className="size-3.5" /> Define
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "defining" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void slice.saveDefinition(work._id, starterDefinition(work))
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save definition
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-definition-approval" &&
|
||||
work.definitionVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void slice.approveDefinition(
|
||||
work._id,
|
||||
work.definitionVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Design
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">
|
||||
{design?.architectureSummary ?? "No Design Packet yet."}
|
||||
</p>
|
||||
{design?.slices?.map((item) => (
|
||||
<p className="mt-1 text-[#747168]" key={item.id}>
|
||||
{item.title}: {item.observableBehavior}
|
||||
</p>
|
||||
))}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "designing" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void slice.saveDesign(work._id, starterDesign(work))
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save design
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-design-approval" &&
|
||||
work.definitionApprovalVersion &&
|
||||
work.designVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void slice.approveDesign(
|
||||
work._id,
|
||||
work.definitionApprovalVersion as number,
|
||||
work.designVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve design
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Build
|
||||
</p>
|
||||
{latestRun ? (
|
||||
<p className="mt-1 leading-5 text-[#626057]">
|
||||
Run {latestRun.status}:{" "}
|
||||
{latestRun.terminalSummary ??
|
||||
latestRun.terminalClassification ??
|
||||
"activity is still arriving"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[#747168]">No simulation Run yet.</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "ready" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void slice.startSimulation(
|
||||
work._id,
|
||||
"success",
|
||||
design?.slices?.[0]?.id
|
||||
)
|
||||
}
|
||||
>
|
||||
<Play className="size-3.5" /> Simulate
|
||||
</Button>
|
||||
) : null}
|
||||
{latestRun?.status === "running" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void slice.cancelSimulation(latestRun._id)}
|
||||
>
|
||||
<X className="size-3.5" /> Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{latestRun?.status === "terminal" &&
|
||||
latestRun.terminalClassification === "RetryableFailure" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void slice.retrySimulation(latestRun._id)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const ConversationLoading = () => (
|
||||
<output
|
||||
aria-label="Loading conversation"
|
||||
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||
>
|
||||
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="sr-only">Loading conversation…</span>
|
||||
</output>
|
||||
);
|
||||
|
||||
const ConversationEmptyState = () => (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem. Casual conversation stays conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
type SliceOneState = ReturnType<typeof useSliceOne>;
|
||||
|
||||
const ProjectsLoading = () => (
|
||||
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
||||
<form
|
||||
className="w-full max-w-sm"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void slice.connectRepository();
|
||||
}}
|
||||
>
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
||||
Slice 1 turns actionable conversation into proposed Work with exact
|
||||
provenance.
|
||||
</p>
|
||||
<input
|
||||
aria-label="Public Git repository URL"
|
||||
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => slice.setRepository(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
required
|
||||
value={slice.repository}
|
||||
/>
|
||||
{slice.error ? (
|
||||
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 h-12 w-full"
|
||||
disabled={slice.pending}
|
||||
type="submit"
|
||||
>
|
||||
{slice.pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{slice.pending ? "Connecting" : "Connect project"}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
export const SliceOnePage = () => {
|
||||
const slice = useSliceOne();
|
||||
const viewportStyle = useVisualViewportStyle();
|
||||
const [draft, setDraft] = useState("");
|
||||
const attachments = useChatImages();
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const works = slice.works ?? EMPTY_WORKS;
|
||||
const workById = useMemo(
|
||||
() => new Map(works.map((work) => [String(work._id), work])),
|
||||
[works]
|
||||
);
|
||||
const notices = useMemo(() => projectWorkNotices(works), [works]);
|
||||
const timeline = useMemo(
|
||||
() => buildSliceOneTimeline(slice.agent.messages, notices),
|
||||
[notices, slice.agent.messages]
|
||||
);
|
||||
const busy =
|
||||
slice.agent.status === "submitted" || slice.agent.status === "streaming";
|
||||
|
||||
const revealSourceMessage = (rawText: string) => {
|
||||
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setHighlightedMessageId(messageId);
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
highlightTimer.current = setTimeout(
|
||||
() => setHighlightedMessageId(undefined),
|
||||
1800
|
||||
);
|
||||
};
|
||||
|
||||
if (slice.projects === undefined) {
|
||||
return <ProjectsLoading />;
|
||||
}
|
||||
|
||||
if (!slice.selectedProject) {
|
||||
return <ConnectProject slice={slice} />;
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
await slice.agent.sendMessage(message, {
|
||||
images: attachments.images.map((image) => image.file),
|
||||
});
|
||||
setDraft("");
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
||||
style={viewportStyle}
|
||||
>
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="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"
|
||||
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
||||
onChange={(event) => slice.selectProject(event.target.value)}
|
||||
value={slice.selectedProject.id}
|
||||
>
|
||||
{slice.projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] uppercase text-[#858277]">
|
||||
Conversation to proposed Work
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Menu className="size-4" /> Work{" "}
|
||||
{slice.works === undefined ? "…" : works.length}
|
||||
</button>
|
||||
</header>
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||
{!slice.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationLoading />
|
||||
) : null}
|
||||
{slice.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationEmptyState />
|
||||
) : null}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "work") {
|
||||
const work = workById.get(item.notice.workId);
|
||||
return work ? (
|
||||
<div
|
||||
className="chat-message ml-7"
|
||||
key={`notice-${item.notice.eventId}`}
|
||||
>
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Work proposed from this conversation
|
||||
</p>
|
||||
<WorkCard
|
||||
onSourceSelect={revealSourceMessage}
|
||||
slice={slice}
|
||||
work={work}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`rounded-sm transition-colors duration-300 ${
|
||||
highlightedMessageId === item.message.id
|
||||
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
|
||||
: ""
|
||||
}`}
|
||||
id={`slice-message-${item.message.id}`}
|
||||
key={item.message.id}
|
||||
>
|
||||
<ChatMessage message={item.message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{slice.agent.status === "submitted" ? (
|
||||
<ChatThinkingResponse />
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{attachments.images.length > 0 ? (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<PendingChatAttachments
|
||||
images={attachments.images}
|
||||
onRemove={attachments.handleRemove}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{slice.agent.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{slice.agent.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{attachments.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{attachments.error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
||||
<input
|
||||
accept="image/*"
|
||||
aria-label="Attach images"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
attachments.addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
aria-label="Attach images"
|
||||
className="size-11 shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
</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-sm outline-none focus:border-[#55564e]"
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim() || busy}
|
||||
onClick={() => void send()}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
||||
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
||||
<p className="mb-4 text-xs text-[#747168]">
|
||||
{works.length} durable outcomes
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
slice={slice}
|
||||
work={work}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{drawerOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="absolute inset-0"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
||||
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
||||
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="grid size-9 place-items-center"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{works.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-[#747168]">
|
||||
Actionable messages will appear here.
|
||||
</p>
|
||||
) : null}
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
slice={slice}
|
||||
work={work}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import * as React from "react";
|
||||
|
||||
export function ThemeProvider({
|
||||
export const ThemeProvider = ({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) => (
|
||||
<NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
);
|
||||
|
||||
export { useTheme } from "next-themes";
|
||||
|
||||
57
apps/web/src/components/timeline/timeline-composer.tsx
Normal file
57
apps/web/src/components/timeline/timeline-composer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Send } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
export const TimelineComposer = ({
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
readonly draft: string;
|
||||
readonly onDraftChange: (draft: string) => void;
|
||||
readonly onSubmit: (text: string) => void;
|
||||
}) => {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const message = draft.trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
onSubmit(message);
|
||||
onDraftChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<form
|
||||
aria-label="Add timeline event"
|
||||
className="mx-auto flex max-w-2xl items-end gap-2"
|
||||
onSubmit={submit}
|
||||
>
|
||||
<textarea
|
||||
aria-label="Add a timeline event"
|
||||
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Add event"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
apps/web/src/components/timeline/timeline-event-list.tsx
Normal file
48
apps/web/src/components/timeline/timeline-event-list.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
|
||||
import type { LocalTimelineEvent } from "@/hooks/use-global-timeline";
|
||||
|
||||
const eventTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const EmptyTimeline = () => (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem to add the first event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TimelineEventList = ({
|
||||
events,
|
||||
endRef,
|
||||
}: {
|
||||
readonly endRef: React.RefObject<HTMLDivElement | null>;
|
||||
readonly events: readonly LocalTimelineEvent[];
|
||||
}) => {
|
||||
if (events.length === 0) {
|
||||
return <EmptyTimeline />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-live="polite" className="flex flex-col gap-5" role="log">
|
||||
{events.map((event) => (
|
||||
<article className="ml-auto max-w-[min(88%,42rem)]" key={event.id}>
|
||||
<div className="border border-[#c9c5b9] bg-white px-4 py-3 text-sm leading-6 text-[#20201d] shadow-[0_1px_0_rgba(32,32,29,0.04)]">
|
||||
{event.text}
|
||||
</div>
|
||||
<p className="mt-1 text-right text-[10px] font-medium tracking-[0.14em] text-[#858277] uppercase">
|
||||
You · {eventTimeFormatter.format(event.createdAt)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<div aria-hidden="true" ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
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 { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
interface WorkRecord {
|
||||
readonly _id: Id<"works">;
|
||||
readonly title: string;
|
||||
readonly objective: string;
|
||||
readonly status: string;
|
||||
readonly definitionVersion?: number;
|
||||
readonly definitionApprovalVersion?: number;
|
||||
readonly designVersion?: number;
|
||||
readonly designApprovalVersion?: number;
|
||||
readonly signals: readonly {
|
||||
sources: readonly { messageId: string; rawText: string }[];
|
||||
}[];
|
||||
readonly events: readonly { _id: string; createdAt: number; kind: string }[];
|
||||
readonly definitions: readonly unknown[];
|
||||
readonly designs: readonly unknown[];
|
||||
readonly slices: readonly unknown[];
|
||||
readonly runs: readonly {
|
||||
_id: Id<"workRuns">;
|
||||
status: string;
|
||||
terminalClassification?: string;
|
||||
terminalSummary?: string;
|
||||
}[];
|
||||
readonly definition: {
|
||||
risk?: string;
|
||||
questions?: { status: string }[];
|
||||
} | null;
|
||||
readonly design: {
|
||||
architectureSummary?: string;
|
||||
slices?: { id: string; title: string; observableBehavior: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
const workListRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
WorkRecord[]
|
||||
>("workPlanning:listForProject");
|
||||
const requestDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works"> },
|
||||
unknown
|
||||
>("workPlanning:requestDefinition");
|
||||
const saveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ 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:
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled";
|
||||
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");
|
||||
|
||||
export const useSliceOne = () => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
workListRef,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
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 selectedProject = useMemo(
|
||||
() =>
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null,
|
||||
[activeProjectId, projects]
|
||||
);
|
||||
|
||||
const connectRepository = async () => {
|
||||
const repositoryUrl = repository.trim();
|
||||
if (!repositoryUrl || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const project = await importPublicGit({ repositoryUrl });
|
||||
setSelectedProjectId(project.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(toError(caughtError));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProject = (projectId: string) => {
|
||||
setSelectedProjectId(projectId as unknown as Id<"projects">);
|
||||
};
|
||||
|
||||
const requestDefinition = (workId: Id<"works">) =>
|
||||
requestDefinitionMutation({ workId });
|
||||
const saveDefinition = (workId: Id<"works">, payload: unknown) =>
|
||||
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId });
|
||||
const approveDefinition = (workId: Id<"works">, version: number) =>
|
||||
approveDefinitionMutation({ version, workId });
|
||||
const saveDesign = (workId: Id<"works">, payload: unknown) =>
|
||||
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId });
|
||||
const approveDesign = (
|
||||
workId: Id<"works">,
|
||||
definitionVersion: number,
|
||||
designVersion: number
|
||||
) => approveDesignMutation({ definitionVersion, designVersion, workId });
|
||||
const startSimulation = (
|
||||
workId: Id<"works">,
|
||||
scenario:
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled",
|
||||
sliceId?: string
|
||||
) => startSimulationMutation({ scenario, sliceId, workId });
|
||||
const cancelSimulation = (runId: Id<"workRuns">) =>
|
||||
cancelSimulationMutation({ runId });
|
||||
const retrySimulation = (runId: Id<"workRuns">) =>
|
||||
retrySimulationMutation({ runId });
|
||||
|
||||
return {
|
||||
agent,
|
||||
approveDefinition,
|
||||
approveDesign,
|
||||
cancelSimulation,
|
||||
connectRepository,
|
||||
error,
|
||||
pending,
|
||||
projects,
|
||||
repository,
|
||||
requestDefinition,
|
||||
retrySimulation,
|
||||
saveDefinition,
|
||||
saveDesign,
|
||||
selectProject,
|
||||
selectedProject,
|
||||
setRepository,
|
||||
startSimulation,
|
||||
works,
|
||||
} as const;
|
||||
};
|
||||
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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]);
|
||||
102
apps/web/src/hooks/use-github-repo-search.ts
Normal file
102
apps/web/src/hooks/use-github-repo-search.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
};
|
||||
31
apps/web/src/hooks/use-global-timeline.ts
Normal file
31
apps/web/src/hooks/use-global-timeline.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export interface LocalTimelineEvent {
|
||||
readonly createdAt: number;
|
||||
readonly id: string;
|
||||
readonly text: string;
|
||||
readonly type: "client.prompt";
|
||||
}
|
||||
|
||||
export const useGlobalTimeline = () => {
|
||||
const [events, setEvents] = useState<readonly LocalTimelineEvent[]>([]);
|
||||
|
||||
const addPrompt = useCallback((text: string) => {
|
||||
const normalizedText = text.trim();
|
||||
if (!normalizedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((current) => [
|
||||
...current,
|
||||
{
|
||||
createdAt: Date.now(),
|
||||
id: crypto.randomUUID(),
|
||||
text: normalizedText,
|
||||
type: "client.prompt",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
return { addPrompt, events };
|
||||
};
|
||||
258
apps/web/src/hooks/use-project-setup.ts
Normal file
258
apps/web/src/hooks/use-project-setup.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Setup event types emitted by the backend project-setup coordinator. The
|
||||
* lifecycle types (`project.setup.*`, `project.ready`, `project.setup.failed`)
|
||||
* are emitted by `projectSetup.runSetup`; the remaining types mirror the
|
||||
* literals accepted by `projects.appendSetupEvent` and returned by
|
||||
* `projects.getSetup`.
|
||||
*/
|
||||
export type ProjectSetupEventType =
|
||||
// Lifecycle event types emitted by the project-setup coordinator.
|
||||
| "project.setup.creating_vm"
|
||||
| "project.setup.cloning"
|
||||
| "project.setup.checking_repository"
|
||||
| "project.ready"
|
||||
| "project.setup.failed"
|
||||
// Legacy / environment / preview event types (compatible).
|
||||
| "project.setup.started"
|
||||
| "project.setup.environment_required"
|
||||
| "project.setup.environment_updated"
|
||||
| "project.setup.ready"
|
||||
| "project.setup.blocked"
|
||||
| "project.preview.ready"
|
||||
| "project.preview.blocked";
|
||||
|
||||
/**
|
||||
* User-facing lifecycle phases derived from the raw setup event stream. The
|
||||
* component layer never renders raw event types — it renders these phases.
|
||||
*/
|
||||
export type ProjectSetupPhase =
|
||||
| "creating_workspace"
|
||||
| "cloning_repository"
|
||||
| "checking_repository"
|
||||
| "ready"
|
||||
| "blocked";
|
||||
|
||||
export interface ProjectSetupEvent {
|
||||
readonly occurredAt: number;
|
||||
readonly payload: unknown;
|
||||
readonly type: ProjectSetupEventType;
|
||||
}
|
||||
|
||||
export interface ProjectSetupState {
|
||||
readonly attempt: number;
|
||||
readonly events: readonly ProjectSetupEvent[];
|
||||
readonly phase: ProjectSetupPhase;
|
||||
readonly retry: () => Promise<void>;
|
||||
readonly retrying: boolean;
|
||||
}
|
||||
|
||||
export type ProjectSetupResult =
|
||||
| { readonly kind: "loading" }
|
||||
| ({ readonly kind: "ready" } & ProjectSetupState);
|
||||
|
||||
/**
|
||||
* Status of a project's durable runtime row, as returned by
|
||||
* `projects.getSetup`. This is the source of truth for UI phase derivation: a
|
||||
* stale `project.setup.failed` event from a prior attempt must NOT keep the UI
|
||||
* blocked once the runtime has been retried into a non-terminal status.
|
||||
*/
|
||||
type RuntimeStatus =
|
||||
| "requested"
|
||||
| "creating_vm"
|
||||
| "cloning"
|
||||
| "checking_repository"
|
||||
| "ready"
|
||||
| "failed";
|
||||
|
||||
interface RuntimeRow {
|
||||
readonly attempt: number;
|
||||
readonly status: RuntimeStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural shape of the `projects.getSetup` return value. We define
|
||||
* this locally rather than importing from generated code so the hook compiles
|
||||
* before the backend regenerates its API manifest. The `runtime` field is the
|
||||
* durable project runtime row (or null before the row exists).
|
||||
*/
|
||||
interface SetupQueryResult {
|
||||
readonly events: readonly {
|
||||
readonly occurredAt: number;
|
||||
readonly payload: unknown;
|
||||
readonly type: ProjectSetupEventType;
|
||||
}[];
|
||||
readonly runtime: {
|
||||
readonly attempt: number;
|
||||
readonly status: RuntimeStatus;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface RetrySetupResult {
|
||||
readonly attempt: number;
|
||||
readonly retried: boolean;
|
||||
}
|
||||
|
||||
const getSetupRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
SetupQueryResult
|
||||
>("projects:getSetup");
|
||||
|
||||
/**
|
||||
* Authenticated retry endpoint. Resets a failed runtime to a non-terminal
|
||||
* status, increments the attempt, and reschedules the existing
|
||||
* `projectSetup:runSetup` coordinator. Returns `{ retried, attempt }`; a
|
||||
* `retried: false` result means the runtime was not failed (e.g. already
|
||||
* ready, or still in-progress) and nothing was re-run.
|
||||
*/
|
||||
const retrySetupRef = makeFunctionReference<
|
||||
"action",
|
||||
{ projectId: Id<"projects"> },
|
||||
RetrySetupResult
|
||||
>("projects:retrySetup");
|
||||
|
||||
const ORDERED_PHASES: readonly ProjectSetupPhase[] = [
|
||||
"creating_workspace",
|
||||
"cloning_repository",
|
||||
"checking_repository",
|
||||
"ready",
|
||||
];
|
||||
|
||||
const typeToPhase = (type: ProjectSetupEventType): ProjectSetupPhase => {
|
||||
switch (type) {
|
||||
case "project.setup.creating_vm":
|
||||
case "project.setup.started": {
|
||||
return "creating_workspace";
|
||||
}
|
||||
case "project.setup.cloning": {
|
||||
return "cloning_repository";
|
||||
}
|
||||
case "project.setup.checking_repository":
|
||||
case "project.setup.environment_required":
|
||||
case "project.setup.environment_updated": {
|
||||
return "checking_repository";
|
||||
}
|
||||
default: {
|
||||
return "creating_workspace";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const RUNTIME_STATUS_TO_PHASE: Record<RuntimeStatus, ProjectSetupPhase> = {
|
||||
checking_repository: "checking_repository",
|
||||
cloning: "cloning_repository",
|
||||
creating_vm: "creating_workspace",
|
||||
failed: "blocked",
|
||||
ready: "ready",
|
||||
requested: "creating_workspace",
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the user-facing phase.
|
||||
*
|
||||
* The durable runtime row is the source of truth. Only when no runtime row
|
||||
* exists yet (the very first import, before the coordinator has persisted a
|
||||
* row) do we fall back to the event stream. A historical `project.setup.failed`
|
||||
* event from a prior attempt never blocks the UI once the current runtime is
|
||||
* non-failed — we never consult events for terminality when a runtime exists.
|
||||
*/
|
||||
const derivePhase = (
|
||||
events: readonly ProjectSetupEvent[],
|
||||
runtime: RuntimeRow | null
|
||||
): ProjectSetupPhase => {
|
||||
if (runtime) {
|
||||
return RUNTIME_STATUS_TO_PHASE[runtime.status];
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return "creating_workspace";
|
||||
}
|
||||
|
||||
const hasReady = events.some(
|
||||
(event) =>
|
||||
event.type === "project.ready" ||
|
||||
event.type === "project.setup.ready" ||
|
||||
event.type === "project.preview.ready"
|
||||
);
|
||||
if (hasReady) {
|
||||
return "ready";
|
||||
}
|
||||
|
||||
let furthestIndex = 0;
|
||||
for (const event of events) {
|
||||
const index = ORDERED_PHASES.indexOf(typeToPhase(event.type));
|
||||
if (index > furthestIndex) {
|
||||
furthestIndex = index;
|
||||
}
|
||||
}
|
||||
return ORDERED_PHASES[furthestIndex] ?? "creating_workspace";
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a project's setup lifecycle via the `projects.getSetup` query,
|
||||
* and expose an authenticated retry control bound to `projects:retrySetup`.
|
||||
*
|
||||
* Returns a loading sentinel while the first fetch is in flight, then a derived
|
||||
* `{ phase, events, attempt, retry, retrying }` state. Phase is derived from
|
||||
* the durable runtime row (the source of truth), so a historical failed event
|
||||
* cannot keep the UI blocked once the runtime has been retried. Components
|
||||
* never receive raw backend errors — `retry` resolves cleanly and surfaces
|
||||
* progress via the reactive query once the coordinator reschedules.
|
||||
*/
|
||||
export const useProjectSetup = (
|
||||
projectId: Id<"projects"> | undefined
|
||||
): ProjectSetupResult => {
|
||||
const setup = useQuery(
|
||||
getSetupRef,
|
||||
projectId === undefined ? "skip" : { projectId }
|
||||
);
|
||||
const retrySetup = useAction(retrySetupRef);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
if (projectId === undefined || setup === undefined) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
|
||||
const events: readonly ProjectSetupEvent[] = setup.events.map((event) => ({
|
||||
occurredAt: event.occurredAt,
|
||||
payload: event.payload,
|
||||
type: event.type,
|
||||
}));
|
||||
const runtime: RuntimeRow | null = setup.runtime
|
||||
? { attempt: setup.runtime.attempt, status: setup.runtime.status }
|
||||
: null;
|
||||
const attempt = runtime?.attempt ?? 1;
|
||||
|
||||
// While a retry is in flight (between the user click and the reactive
|
||||
// runtime-status update landing), force an in-progress phase so the UI never
|
||||
// lingers on the stale blocked state from the prior failed attempt.
|
||||
const basePhase = derivePhase(events, runtime);
|
||||
const phase: ProjectSetupPhase =
|
||||
retrying && basePhase === "blocked" ? "creating_workspace" : basePhase;
|
||||
|
||||
const retry = async (): Promise<void> => {
|
||||
if (projectId === undefined || retrying) {
|
||||
return;
|
||||
}
|
||||
// The retry endpoint is idempotent and self-scoping: it only re-runs when
|
||||
// the runtime is failed. Swallow rejections so raw backend errors never
|
||||
// reach the UI; progress is observed reactively through getSetup once the
|
||||
// coordinator reschedules (runtime status flips to non-terminal).
|
||||
setRetrying(true);
|
||||
try {
|
||||
await retrySetup({ projectId });
|
||||
} catch {
|
||||
// No user-visible raw error; the reactive query keeps the UI honest
|
||||
// about the true runtime state.
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { attempt, events, kind: "ready", phase, retry, retrying };
|
||||
};
|
||||
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,80 +1,10 @@
|
||||
@import "@code/ui/globals.css";
|
||||
@import "streamdown/styles.css";
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html.slice-one-viewport-lock,
|
||||
html.slice-one-viewport-lock body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.ai-conversation-mobile > div {
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
animation: chat-message-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.response-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.response-state-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.response-state-dots i,
|
||||
.thinking-line span {
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
animation: response-dot 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.response-state-dots i {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.response-state-dots i:nth-child(2),
|
||||
.thinking-line span:nth-child(2) {
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
.response-state-dots i:nth-child(3),
|
||||
.thinking-line span:nth-child(3) {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
.thinking-line {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: color-mix(in oklch, var(--foreground) 42%, transparent);
|
||||
}
|
||||
|
||||
.thinking-line span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.chat-markdown[data-streamdown="true"] {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.route-progress-bar {
|
||||
animation: route-progress 900ms ease-in-out infinite;
|
||||
}
|
||||
@@ -87,149 +17,3 @@ html.slice-one-viewport-lock body {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes response-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
transform: translateY(0) scale(0.84);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-message-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-markdown {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.slice-one-surface .chat-markdown,
|
||||
.slice-one-surface .chat-reasoning,
|
||||
.slice-one-surface .thinking-line {
|
||||
color: #232321;
|
||||
}
|
||||
|
||||
.chat-markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-markdown p,
|
||||
.chat-markdown ul,
|
||||
.chat-markdown ol,
|
||||
.chat-markdown blockquote,
|
||||
.chat-markdown pre,
|
||||
.chat-markdown table {
|
||||
margin-block: 0.8rem;
|
||||
}
|
||||
|
||||
.chat-markdown h1,
|
||||
.chat-markdown h2,
|
||||
.chat-markdown h3,
|
||||
.chat-markdown h4 {
|
||||
margin-block: 1.4rem 0.65rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.chat-markdown h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.chat-markdown h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.chat-markdown h3,
|
||||
.chat-markdown h4 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.chat-markdown ul,
|
||||
.chat-markdown ol {
|
||||
padding-inline-start: 1.4rem;
|
||||
}
|
||||
|
||||
.chat-markdown ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.chat-markdown ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.chat-markdown li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.chat-markdown blockquote {
|
||||
border-inline-start: 2px solid var(--border);
|
||||
padding-inline-start: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chat-markdown a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--muted-foreground);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.chat-markdown :not(pre) > code {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.chat-markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
padding: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-markdown table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.chat-markdown th,
|
||||
.chat-markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-markdown *,
|
||||
.chat-message,
|
||||
.cn-message-scroller-button,
|
||||
.response-state-dots i,
|
||||
.thinking-line span {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
interface AuthLoaderData {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
|
||||
|
||||
export const loadAuthToken = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
@@ -14,9 +10,12 @@ export const loadAuthToken = async (
|
||||
if (!cookie) {
|
||||
return { token: null };
|
||||
}
|
||||
const tokenUrl = new URL(
|
||||
"/api/auth/convex/token",
|
||||
new URL(request.url).origin
|
||||
);
|
||||
|
||||
const headers = new Headers({ cookie });
|
||||
headers.set("host", tokenUrl.host);
|
||||
const response = await fetch(tokenUrl, { headers });
|
||||
if (response.status === 401) {
|
||||
return { token: null };
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ConversationRow } from "./conversation";
|
||||
import { projectConversation } from "./conversation";
|
||||
|
||||
const row = (
|
||||
overrides: Partial<ConversationRow> &
|
||||
Pick<ConversationRow, "messageId" | "role" | "status">
|
||||
): ConversationRow => ({
|
||||
attachments: [],
|
||||
error: null,
|
||||
rawText: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("projectConversation", () => {
|
||||
test("keeps queued assistant rows as status instead of duplicate messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "user-1",
|
||||
rawText: "Build it",
|
||||
role: "user",
|
||||
status: "processing",
|
||||
}),
|
||||
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
|
||||
]);
|
||||
expect(state.pending).toBe(true);
|
||||
expect(state.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("projects completed Convex rows into renderable messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "assistant-1",
|
||||
rawText: "Captured the request.",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
expect(state.messages[0]?.parts).toEqual([
|
||||
{ state: "done", text: "Captured the request.", type: "text" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { ConversationMessage } from "./types";
|
||||
|
||||
export interface ConversationRow {
|
||||
readonly attachments: readonly {
|
||||
readonly filename: string | null;
|
||||
readonly id: string;
|
||||
readonly mediaType: string;
|
||||
readonly url: string | null;
|
||||
}[];
|
||||
readonly error: string | null;
|
||||
readonly messageId: string;
|
||||
readonly rawText: string;
|
||||
readonly role: "assistant" | "user";
|
||||
readonly status: "completed" | "failed" | "processing" | "queued";
|
||||
}
|
||||
|
||||
export const projectConversation = (rows: readonly ConversationRow[]) => ({
|
||||
failedError: rows.findLast(
|
||||
(row) => row.role === "assistant" && row.status === "failed"
|
||||
)?.error,
|
||||
messages: rows
|
||||
.filter((row) => row.role === "user" || row.status === "completed")
|
||||
.map<ConversationMessage>((row) => ({
|
||||
id: row.messageId,
|
||||
parts: [
|
||||
...row.attachments.map((attachment) => ({
|
||||
filename: attachment.filename ?? undefined,
|
||||
id: attachment.id,
|
||||
mediaType: attachment.mediaType,
|
||||
type: "file" as const,
|
||||
url: attachment.url ?? undefined,
|
||||
})),
|
||||
...(row.rawText
|
||||
? [
|
||||
{
|
||||
state: "done" as const,
|
||||
text: row.rawText,
|
||||
type: "text" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
role: row.role,
|
||||
})),
|
||||
pending: rows.some(
|
||||
(row) =>
|
||||
row.role === "assistant" &&
|
||||
(row.status === "queued" || row.status === "processing")
|
||||
),
|
||||
});
|
||||
@@ -7,7 +7,8 @@ export default [
|
||||
route("signup", "./routes/auth/signup/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [
|
||||
index("./routes/app/mobile/page.tsx"),
|
||||
route("dashboard", "./routes/app/dashboard/page.tsx"),
|
||||
route("timeline", "./routes/app/timeline/page.tsx", { id: "timeline" }),
|
||||
index("./routes/app/timeline/page.tsx"),
|
||||
route("projects", "./routes/app/projects/page.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
export default function Dashboard() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
export default function MobileLandingRedirect() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
144
apps/web/src/routes/app/projects/page.tsx
Normal file
144
apps/web/src/routes/app/projects/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
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 { ProjectSetupPanel } from "@/components/projects/project-setup-panel";
|
||||
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";
|
||||
|
||||
interface SetupTarget {
|
||||
readonly id: Id<"projects">;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
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 [setupTarget, setSetupTarget] = useState<SetupTarget>();
|
||||
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) => {
|
||||
pageState.closeAddProject();
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
setSetupTarget({
|
||||
id: projectId as Id<"projects">,
|
||||
name: project?.name ?? "Project",
|
||||
});
|
||||
};
|
||||
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 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]">
|
||||
Projects keep your repository source and context in one place.
|
||||
Connect a Git provider or import a public repository to start.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{pageState.isAddProjectOpen ? (
|
||||
<AddProjectPanel
|
||||
accounts={providerAccounts}
|
||||
existingSourceUrls={existingSourceUrls}
|
||||
onClose={handleCloseAddProject}
|
||||
onProjectCreated={handleProjectCreated}
|
||||
/>
|
||||
) : null}
|
||||
{setupTarget ? (
|
||||
<ProjectSetupPanel
|
||||
onClose={() => setSetupTarget(undefined)}
|
||||
onEnterProject={() => {
|
||||
setSetupTarget(undefined);
|
||||
navigate("/");
|
||||
}}
|
||||
projectId={setupTarget.id}
|
||||
projectName={setupTarget.name}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/routes/app/timeline/page.tsx
Normal file
54
apps/web/src/routes/app/timeline/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { TimelineComposer } from "@/components/timeline/timeline-composer";
|
||||
import { TimelineEventList } from "@/components/timeline/timeline-event-list";
|
||||
import { useGlobalTimeline } from "@/hooks/use-global-timeline";
|
||||
|
||||
export default function GlobalTimelineRoute() {
|
||||
const [draft, setDraft] = useState("");
|
||||
const { addPrompt, events } = useGlobalTimeline();
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length > 0) {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
return (
|
||||
<main className="fixed inset-0 flex min-h-0 flex-col overflow-hidden bg-[#f2f0e7] text-[#20201d]">
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-sm font-semibold">Global timeline</h1>
|
||||
<p className="text-[10px] tracking-[0.14em] text-[#858277] uppercase">
|
||||
Organization activity
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
aria-label="Projects"
|
||||
className="grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
|
||||
to="/projects"
|
||||
>
|
||||
<FolderGit2 className="size-4" />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section
|
||||
aria-label="Global timeline events"
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto min-h-full w-full max-w-2xl px-4 py-5 sm:px-6">
|
||||
<TimelineEventList endRef={endRef} events={events} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TimelineComposer
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={addPrompt}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,27 @@ import path from "node:path";
|
||||
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite-plus";
|
||||
import { defineConfig, loadEnv } from "vite-plus";
|
||||
|
||||
export default defineConfig({
|
||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
dedupe: ["convex", "react", "react-dom"],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
59
deploy/vds/Caddyfile
Normal file
59
deploy/vds/Caddyfile
Normal file
@@ -0,0 +1,59 @@
|
||||
# Caddy reverse proxy for the Zopu VDS staging agent stack.
|
||||
#
|
||||
# This is the ONLY public ingress to the single agents Node process (see
|
||||
# docs/DEPLOYMENT_PLAN.md §"Public surface"). It terminates TLS for
|
||||
# agents.zopu.puter.wtf — the Flue callback hostname Convex reaches — then
|
||||
# forwards ONLY the documented worker paths to the internal `agents` service at
|
||||
# 127.0.0.1:3000.
|
||||
#
|
||||
# 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 paths the Convex backend actually calls are routed:
|
||||
# /health liveness probe
|
||||
# /internal/project-setup single-call project setup coordinator
|
||||
# /internal/agents/<agentId>/events agent event dispatch callback
|
||||
# - The AgentOS registry runs in-process as a native envoy (Rivet Mode A),
|
||||
# so there is no /internal/rivet/* route to expose. Exposing a private
|
||||
# worker protocol publicly would widen the attack surface needlessly.
|
||||
# - Former routes (/agents/zopu/*, /internal/work-attempts/*, /workflows/*,
|
||||
# /api/rivet/*) are intentionally absent: they are stale and not part of the
|
||||
# current Convex→agents contract.
|
||||
# - Everything else returns 404. The private worker protocol stays narrow.
|
||||
#
|
||||
# TLS certs persist in Caddy's data directory. Caddy serves its own ACME
|
||||
# HTTP-01 challenge responses on :80 automatically, so the :80 block only
|
||||
# redirects everything else to HTTPS — worker traffic is never served over
|
||||
# plain HTTP.
|
||||
#
|
||||
# Install/update: see the "Caddy" section of deploy/vds/README.md. In short,
|
||||
# copy this file to /etc/caddy/Caddyfile, then:
|
||||
# sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
# sudo systemctl reload caddy
|
||||
|
||||
agents.zopu.puter.wtf {
|
||||
encode zstd gzip
|
||||
|
||||
# Explicit route block pins directive order: path-matched proxies run
|
||||
# first, then the terminal 404 fallback. No directive is left to the
|
||||
# default sort.
|
||||
route {
|
||||
# Liveness probe (CI healthcheck). Routed to the real worker so a failed
|
||||
# process is not masked by a synthetic success.
|
||||
reverse_proxy /health 127.0.0.1:3000
|
||||
|
||||
# Single-call project setup coordinator. Requires the internalRoute bearer.
|
||||
reverse_proxy /internal/project-setup 127.0.0.1:3000
|
||||
|
||||
# Agent event dispatch callback. Requires the internalRoute bearer.
|
||||
reverse_proxy /internal/agents/* 127.0.0.1:3000
|
||||
|
||||
# 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
|
||||
}
|
||||
119
deploy/vds/README.md
Normal file
119
deploy/vds/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Zopu VDS agents deployment
|
||||
|
||||
This directory holds the systemd unit, environment template, and Caddy reverse-proxy config that run the Zopu intelligence runtime on the VDS. It supersedes the former container deployment for the agents process.
|
||||
|
||||
## Runtime topology
|
||||
|
||||
One Node process hosts the entire runtime:
|
||||
|
||||
```
|
||||
zopu-agents.service
|
||||
└─ node /srv/zopu/current/packages/agents/dist/server.mjs
|
||||
├─ Hono server 127.0.0.1:3000
|
||||
├─ Flue 2.0 agents SQLite at /srv/zopu/data/flue/flue.db
|
||||
└─ AgentOS registry envoy, native runtime (in-process)
|
||||
```
|
||||
|
||||
The process connects outbound to the **Rivet Engine**, a separate private service on `127.0.0.1:6420`, managed by its own unit/compose. This unit does not start or own it.
|
||||
|
||||
The Hono/Flue server handles Convex callbacks and dispatches to Flue agents. The **AgentOS actor is hosted in-process**: the same process is the envoy that owns the native Actor Runtime Socket (Rivet Mode A). There is no separate runner process and no Rivet HTTP handler route.
|
||||
|
||||
### Rivet endpoint
|
||||
|
||||
| Variable | Role | Value on this host |
|
||||
| --- | --- | --- |
|
||||
| `RIVET_ENDPOINT` | Engine **control plane** this process connects _to_ for actor metadata | `http://127.0.0.1:6420` |
|
||||
|
||||
## Files
|
||||
|
||||
- `zopu-agents.service` — systemd unit. Runs `/srv/zopu/current/packages/agents/dist/server.mjs` as user `zopu`, waits for engine health before binding, hardens the service, and restarts on failure.
|
||||
- `agents.env.example` — template for `/etc/zopu/agents.env`. Contains the full validated env schema (Hono/Flue + in-process envoy) with no secrets.
|
||||
- `../../scripts/release-agents.sh` — local build, upload, and atomic release promotion command. Each release includes this directory so the unit's `Documentation=` target remains valid.
|
||||
- `Caddyfile` — source-controlled public ingress for `agents.zopu.puter.wtf`. Terminates TLS and forwards only `/health`, `/internal/project-setup`, and `/internal/agents/*` to `127.0.0.1:3000`.
|
||||
|
||||
## Prerequisites on the VDS
|
||||
|
||||
1. **Node 24** installed at `/opt/zopu/node/bin/node` (the verified VDS Node; the unit's `ExecStart` and `PATH` are anchored on this path). The release bundle ships its own `node_modules`; only the Node binary is expected from the host.
|
||||
2. **`curl`** and **`bash`** on `PATH` (used by the `ExecStartPre` engine health probe).
|
||||
3. A dedicated service user and group:
|
||||
```sh
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin zopu
|
||||
```
|
||||
4. The two writable state trees, owned by `zopu`:
|
||||
```sh
|
||||
sudo mkdir -p /srv/zopu/data/flue /srv/zopu/workspaces
|
||||
sudo chown -R zopu:zopu /srv/zopu/data /srv/zopu/workspaces
|
||||
```
|
||||
5. The **Rivet Engine** running and answering `http://127.0.0.1:6420/health`.
|
||||
6. A release promoted by `scripts/release-agents.sh` to `/srv/zopu/releases/<release-id>`, with `/srv/zopu/current` symlinked to it. The release must contain `packages/agents/dist/server.mjs` and `deploy/vds/`.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
# 1. Secrets
|
||||
sudo install -d -m 0750 -o root -g zopu /etc/zopu
|
||||
sudo cp agents.env.example /etc/zopu/agents.env
|
||||
sudo chown root:zopu /etc/zopu/agents.env
|
||||
sudo chmod 0600 /etc/zopu/agents.env
|
||||
sudo "$EDITOR" /etc/zopu/agents.env # fill in every REPLACE-WITH-...
|
||||
|
||||
# 2. Unit
|
||||
sudo install -m 0644 zopu-agents.service /etc/systemd/system/zopu-agents.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now zopu-agents.service
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
systemctl status zopu-agents.service
|
||||
curl -fsS http://127.0.0.1:3000/health # {"service":"zopu-agents","status":"ok"}
|
||||
```
|
||||
|
||||
### Public ingress (Caddy)
|
||||
|
||||
Caddy terminates TLS for `agents.zopu.puter.wtf` and forwards only the three paths the Convex backend calls to the internal agents worker (`127.0.0.1:3000`). The AgentOS actor registry runs in-process, so no `/internal/rivet/*` path is exposed on the agents listener. All other paths return `404`.
|
||||
|
||||
Install or update from this checked-in artifact:
|
||||
|
||||
```sh
|
||||
# 1. Materialize the config
|
||||
sudo install -m 0644 Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
# 2. Validate before applying
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
|
||||
# 3. Reload the running Caddy (no restart needed)
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Verify the narrow public surface:
|
||||
|
||||
```sh
|
||||
curl -fsS https://agents.zopu.puter.wtf/health # {"service":"zopu-agents","status":"ok"}
|
||||
```
|
||||
|
||||
## Release switch
|
||||
|
||||
From the repository root on the build host, create and promote a release:
|
||||
|
||||
```sh
|
||||
scripts/release-agents.sh <vds-host> <release-id>
|
||||
```
|
||||
|
||||
The script atomically swaps `/srv/zopu/current`; it deliberately does **not** restart the service. After the upload succeeds, restart the unit:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart zopu-agents.service
|
||||
```
|
||||
|
||||
Roll back by atomically replacing `current` with a symlink to a prior release, then restarting the service.
|
||||
|
||||
## Optional: explicit engine ordering
|
||||
|
||||
The unit already gates startup on the engine health endpoint via `ExecStartPre`. If the engine is itself a named systemd unit on this host, you may add a hard ordering by uncommenting/adding in the `[Unit]` section:
|
||||
|
||||
```ini
|
||||
After=rivet-engine.service
|
||||
Wants=rivet-engine.service
|
||||
```
|
||||
62
deploy/vds/agents.env.example
Normal file
62
deploy/vds/agents.env.example
Normal file
@@ -0,0 +1,62 @@
|
||||
# /etc/zopu/agents.env — runtime environment for zopu-agents.service
|
||||
#
|
||||
# Install at /etc/zopu/agents.env, owner root:zopu, mode 0600. Fill in every
|
||||
# REPLACE-WITH-... value; the process validates the full schema at start and
|
||||
# will refuse to boot if a required key is missing or malformed. This file is
|
||||
# the single source of secrets for the VDS agents process — never commit a
|
||||
# filled-in copy.
|
||||
|
||||
# --- Process listener --------------------------------------------------------
|
||||
# Binds the private loopback only. Caddy (or an equivalent reverse proxy)
|
||||
# terminates TLS upstream and forwards to 127.0.0.1:3000.
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# --- Rivet Engine ------------------------------------------------------------
|
||||
#
|
||||
# RIVET_ENDPOINT is the Engine CONTROL PLANE this process connects TO for actor
|
||||
# metadata/management. The engine is a separate private service on this host.
|
||||
RIVET_ENDPOINT=http://127.0.0.1:6420
|
||||
#
|
||||
# Shared secret authenticating actor connections between the in-process envoy
|
||||
# and the AgentOS project workspace actor. Must match the value used by the
|
||||
# engine.
|
||||
RIVET_WORKSPACE_TOKEN=REPLACE-WITH-LONG-RANDOM-WORKSPACE-TOKEN
|
||||
|
||||
# RivetKit runtime selection. These make the native Mode A choice explicit;
|
||||
# the registry still connects outbound to the private Engine.
|
||||
RIVETKIT_RUNTIME_MODE=envoy
|
||||
RIVETKIT_RUNTIME=native
|
||||
|
||||
# --- Actor versioning --------------------------------------------------------
|
||||
#
|
||||
# RivetKit uses this to drain actors when the envoy version changes. Bump it
|
||||
# whenever the actor definition or envoy behavior changes in a way that cannot
|
||||
# coexist with the previous version.
|
||||
RIVET_ENVOY_VERSION=1
|
||||
|
||||
# --- Flue SQLite persistence -------------------------------------------------
|
||||
# Canonical conversation state. Parent dir must be one of the unit's
|
||||
# ReadWritePaths (/srv/zopu/data).
|
||||
FLUE_DB_PATH=/srv/zopu/data/flue/flue.db
|
||||
# Bearer token shared with Convex for /internal/* service calls.
|
||||
FLUE_DB_TOKEN=REPLACE-WITH-LONG-RANDOM-SERVICE-TOKEN
|
||||
|
||||
# --- Project workspaces ------------------------------------------------------
|
||||
# Root for project-bound AgentOS VMs and generated artifacts. Must be one of
|
||||
# the unit's ReadWritePaths (/srv/zopu/workspaces).
|
||||
AGENT_WORKSPACE_ROOT=/srv/zopu/workspaces
|
||||
|
||||
# --- Convex control plane ----------------------------------------------------
|
||||
CONVEX_URL=REPLACE-WITH-DEPLOYMENT.convex.cloud
|
||||
CONVEX_SITE_URL=REPLACE-WITH-DEPLOYMENT.convex.site
|
||||
|
||||
# --- Agent model provider (OpenAI-compatible) --------------------------------
|
||||
AGENT_MODEL_PROVIDER=cheaptricks
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_NAME=mimo-v2.5
|
||||
AGENT_MODEL_BASE_URL=https://ai.example.com/v1
|
||||
AGENT_MODEL_API_KEY=REPLACE-WITH-PROVIDER-API-KEY
|
||||
AGENT_MODEL_CONTEXT_WINDOW=1048576
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
72
deploy/vds/zopu-agents.service
Normal file
72
deploy/vds/zopu-agents.service
Normal file
@@ -0,0 +1,72 @@
|
||||
# zopu-agents.service — single Zopu intelligence runtime for the VDS.
|
||||
#
|
||||
# One process hosts both the Hono/Flue server and the AgentOS registry running
|
||||
# in native envoy mode (Rivet Mode A):
|
||||
#
|
||||
# zopu-agents (this unit)
|
||||
# └─ node /srv/zopu/current/packages/agents/dist/server.mjs
|
||||
# ├─ Hono HTTP server on 127.0.0.1:3000
|
||||
# ├─ Flue 2.0 conversation agents + SQLite at /srv/zopu/data/flue
|
||||
# └─ AgentOS registry in envoy/native mode (registry.startAndWait)
|
||||
#
|
||||
# The AgentOS actor is hosted in-process: the Hono/Flue process is also the
|
||||
# envoy that connects outbound to the Rivet Engine. There is no separate
|
||||
# runner process and no Rivet HTTP handler route.
|
||||
#
|
||||
# The Rivet Engine is a SEPARATE private service on 127.0.0.1:6420, managed
|
||||
# by its own unit/compose. This unit does not start or own it; it only waits
|
||||
# for the engine health endpoint before binding (ExecStartPre below).
|
||||
# RIVET_ENDPOINT is the engine control plane (http://127.0.0.1:6420).
|
||||
#
|
||||
# The release is a self-contained bundle under /srv/zopu/releases/<id> with
|
||||
# /srv/zopu/current as the live symlink. This unit always runs `current`; the
|
||||
# release script swaps it atomically, then the operator restarts this service.
|
||||
|
||||
[Unit]
|
||||
Description=Zopu agents intelligence runtime (Hono + Flue + in-process envoy)
|
||||
# Soft network ordering. The real gate on the separate Engine is the health
|
||||
# probe in ExecStartPre. If the engine is itself a named systemd unit on this
|
||||
# host, add an explicit ordering here, e.g.:
|
||||
# After=rivet-engine.service
|
||||
# Wants=rivet-engine.service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=zopu
|
||||
Group=zopu
|
||||
WorkingDirectory=/srv/zopu/current
|
||||
|
||||
# Secrets and runtime config live in /etc/zopu/agents.env (chmod 600, owner
|
||||
# root:zopu). See agents.env.example for the full schema; never commit real
|
||||
# values. NODE_ENV is pinned here so a release can never accidentally start in
|
||||
# development mode.
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=/etc/zopu/agents.env
|
||||
|
||||
# Wait for the separate Rivet Engine control plane to report healthy before
|
||||
# binding. The engine is NOT managed by this unit; this probe is the startup
|
||||
# dependency on its health. Bounded to ~60s.
|
||||
ExecStartPre=/usr/bin/env bash -c 'i=0; until curl -fsS -o /dev/null http://127.0.0.1:6420/health; do i=$$((i+1)); [ "$$i" -ge 60 ] && { echo "rivet engine not healthy at http://127.0.0.1:6420/health" >&2; exit 1; }; sleep 1; done'
|
||||
|
||||
# Built release artifact. Node is the verified VDS install at
|
||||
# /opt/zopu/node/bin/node (Node 24). PATH is anchored on that directory so the
|
||||
# Node binary and its bundled toolchain (corepack/pnpm) resolve deterministically.
|
||||
Environment=PATH=/opt/zopu/node/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/opt/zopu/node/bin/node /srv/zopu/current/packages/agents/dist/server.mjs
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# --- Hardening ---------------------------------------------------------------
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
# Writable state: Flue SQLite + project workspaces. The release tree under
|
||||
# /srv/zopu/current is treated read-only at runtime.
|
||||
ReadWritePaths=/srv/zopu/workspaces /srv/zopu/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,92 +0,0 @@
|
||||
###############################################################################
|
||||
# Zopu Runtime Deployment — Environment Template
|
||||
#
|
||||
# Copy to .env and fill in real values. Never commit .env to the repository.
|
||||
# This file documents every environment group required by the single-node
|
||||
# execution plane. Lines marked REQUIRED must be set before first start.
|
||||
###############################################################################
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Convex (control plane) — REQUIRED
|
||||
# Public URLs for the self-hosted Convex deployment.
|
||||
# ---------------------------------------------------------------------------
|
||||
CONVEX_URL=https://your-deployment.convex.cloud
|
||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
||||
SITE_URL=http://localhost:13100
|
||||
VITE_AUTH_URL=http://localhost:13100
|
||||
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
||||
VITE_FLUE_URL=http://localhost:3583
|
||||
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
||||
|
||||
# Self-hosted Convex origins used by convex/docker-compose.yml
|
||||
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
||||
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
|
||||
CONVEX_INSTANCE_NAME=zopu-production
|
||||
CONVEX_INSTANCE_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
|
||||
# The agent daemon clones repos and creates PRs through Gitea.
|
||||
# ---------------------------------------------------------------------------
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=replace-with-gitea-api-token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Model gateway — REQUIRED
|
||||
# All model calls route through this OpenAI-compatible endpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
AGENT_MODEL_PROVIDER=cheaptricks
|
||||
AGENT_MODEL_NAME=glm-5.2
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
|
||||
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
|
||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown)
|
||||
# registry.start() in the daemon boots an in-process RivetKit engine
|
||||
# (envoy mode) backed by a native Rust sidecar. createClient() connects
|
||||
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
|
||||
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
|
||||
# library default (http://localhost:6420).
|
||||
# ---------------------------------------------------------------------------
|
||||
#RIVET_ENDPOINT=http://localhost:6420
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Zopu agent service (Flue)
|
||||
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
|
||||
# zopu-agent.service pins the Flue Node server to port 3583.
|
||||
# ---------------------------------------------------------------------------
|
||||
FLUE_DB_TOKEN=replace-with-long-random-token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Daemon identity
|
||||
# ---------------------------------------------------------------------------
|
||||
DAEMON_ID=zopu-dedicated
|
||||
DAEMON_NAME=Zopu-Dedicated-Server
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Docker sandbox
|
||||
# The zopu service user is added to the docker group during bootstrap.
|
||||
# Orb sandboxes will use Docker for full-system isolation, but the Orb
|
||||
# lane contract has not landed yet. Docker access is provisioned now so
|
||||
# the boundary is ready; no Docker-backed sandbox code is wired today.
|
||||
# ---------------------------------------------------------------------------
|
||||
# No env vars required; Docker socket access is via group membership.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Service authentication / secrets
|
||||
# These tokens authenticate inter-service calls. Generate strong randoms.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
|
||||
#AUTH_SECRET=replace-with-64-char-hex
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Private networking (Tailscale) — OPTIONAL
|
||||
# When Tailscale is available, set the hostname for private DNS.
|
||||
# ---------------------------------------------------------------------------
|
||||
#TAILSCALE_HOSTNAME=zopu-runtime
|
||||
@@ -1,306 +0,0 @@
|
||||
# Zopu Single-Node Runtime Deployment
|
||||
|
||||
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Debian Dedicated Server │
|
||||
│ ~12 CPU cores · ~40 GB RAM · single-node │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
|
||||
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
|
||||
│ │ :13100 │ │ server.mjs │ │ backend │ │
|
||||
│ │ │ │ :3583 │ │ :3210/:3211 │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────▼───────┐ │
|
||||
│ │ zopu-daemon │ Bun + Effect + RivetKit :6420 │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
│ systemd timers: health-check (60s), docker-cleanup (daily) │
|
||||
│ cron: disk-monitor (daily 06:00) │
|
||||
│ ufw: deny-incoming, SSH + tailscale0 │
|
||||
│ Tailscale: optional private overlay │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Single-node RivetKit topology
|
||||
|
||||
The daemon calls `registry.start()` from `rivetkit`, which boots an **in-process RivetKit engine** (envoy mode) backed by a **native Rust sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process engine for actor dispatch.
|
||||
|
||||
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — `DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method calls `#startEnvoy()` → `runtime.serveRegistry()` for serverful mode (Mode A). The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the same `http://localhost:6420`.
|
||||
|
||||
**No separate Rivet Engine process is required.** The engine, actor envoy, and sidecar all run inside the daemon process. A future multi-node deployment would externalize the engine, but that is out of scope.
|
||||
|
||||
### Docker / Orb boundary
|
||||
|
||||
Docker Engine is installed and the `zopu` service user is in the `docker` group. The daemon's systemd unit includes `SupplementaryGroups=docker`. However, **no Docker-backed sandbox code is currently wired**. The Orb sandbox lane contract has not landed; Docker access is provisioned now so the boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8) sandbox, not Docker.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
deploy/zopu-runtime/
|
||||
├── bootstrap.sh # One-shot Debian installer
|
||||
├── .env.template # Environment template (all groups documented)
|
||||
├── README.md # This file (runbook)
|
||||
├── systemd/
|
||||
│ ├── zopu-daemon.service # Daemon systemd unit
|
||||
│ ├── zopu-agent.service # Agent systemd unit
|
||||
│ ├── zopu-web.service # React Router web systemd unit
|
||||
│ ├── zopu-health.service # Health check oneshot
|
||||
│ ├── zopu-health.timer # Health check every 60s
|
||||
│ ├── zopu-docker-cleanup.service
|
||||
│ └── zopu-docker-cleanup.timer
|
||||
├── scripts/
|
||||
│ ├── health-check.sh # TCP/process health probes
|
||||
│ ├── update.sh # Update to branch or commit
|
||||
│ ├── rollback.sh # Roll back to previous commit
|
||||
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
|
||||
│ └── disk-monitor.sh # Disk usage alerting
|
||||
└── caddy/
|
||||
└── Caddyfile # Optional reverse proxy config (documentation)
|
||||
```
|
||||
|
||||
## Fresh install
|
||||
|
||||
```bash
|
||||
# 1. SSH into the fresh Debian 12 server as root.
|
||||
|
||||
# 2. Set environment overrides (optional):
|
||||
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
|
||||
export ZOPU_REPO_BRANCH="dogfood/v0"
|
||||
# export TAILSCALE_AUTHKEY="tskey-..."
|
||||
# export TAILSCALE_HOSTNAME="zopu-runtime"
|
||||
|
||||
# 3. Run the bootstrap script:
|
||||
bash bootstrap.sh
|
||||
|
||||
# 4. Edit .env with real values:
|
||||
nano /opt/zopu/.env
|
||||
|
||||
# 5. Start services:
|
||||
systemctl start zopu-web zopu-daemon
|
||||
sleep 3
|
||||
systemctl start zopu-agent
|
||||
|
||||
# 6. Enable timers:
|
||||
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
|
||||
|
||||
# 7. Verify:
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
||||
```
|
||||
|
||||
## Start / stop / restart
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
systemctl start zopu-web zopu-daemon zopu-agent
|
||||
|
||||
# Stop all services
|
||||
systemctl stop zopu-agent zopu-daemon zopu-web
|
||||
|
||||
# Restart (daemon first — it owns the RivetKit engine)
|
||||
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
|
||||
|
||||
# Enable on boot
|
||||
systemctl enable zopu-web zopu-daemon zopu-agent
|
||||
|
||||
# Disable on boot
|
||||
systemctl disable zopu-web zopu-daemon zopu-agent
|
||||
```
|
||||
|
||||
## Log inspection
|
||||
|
||||
All service logs go to journald with `SyslogIdentifier` tags.
|
||||
|
||||
```bash
|
||||
# Daemon logs (live follow)
|
||||
journalctl -u zopu-daemon -f
|
||||
|
||||
# Agent logs (live follow)
|
||||
journalctl -u zopu-agent -f
|
||||
|
||||
# Last 100 lines of daemon
|
||||
journalctl -u zopu-daemon -n 100
|
||||
|
||||
# Logs since boot
|
||||
journalctl -u zopu-daemon -b
|
||||
|
||||
# Health check timer logs
|
||||
journalctl -u zopu-health.service -n 50
|
||||
|
||||
# Docker cleanup logs
|
||||
journalctl -u zopu-docker-cleanup.service -n 50
|
||||
|
||||
# Disk monitor logs (cron → file)
|
||||
tail -100 /var/log/zopu/disk-monitor.log
|
||||
|
||||
# All Zopu syslog identifiers
|
||||
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
|
||||
```
|
||||
|
||||
## Health checks
|
||||
|
||||
```bash
|
||||
# Manual health check (prints all probes)
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
||||
|
||||
# Quiet mode (exit code only)
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
||||
|
||||
# Check systemd timer is running
|
||||
systemctl status zopu-health.timer
|
||||
systemctl list-timers zopu-health.timer
|
||||
```
|
||||
|
||||
The health check probes:
|
||||
|
||||
1. `zopu-daemon` systemd unit is active
|
||||
2. `zopu-agent` systemd unit is active
|
||||
3. RivetKit engine port (default 6420) accepts TCP connections
|
||||
4. Flue agent port (default 3583) accepts TCP connections
|
||||
5. Docker daemon responds to `docker info`
|
||||
|
||||
No HTTP health endpoints are assumed. Flue does not expose one by design (per Flue docs: "Flue does not add a health endpoint"). RivetKit's health route is internal to the registry runtime and not documented as publicly addressable on the engine endpoint.
|
||||
|
||||
## Update to commit
|
||||
|
||||
```bash
|
||||
# Update to latest of dogfood/v0 (default)
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
|
||||
|
||||
# Update to a specific branch
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
|
||||
|
||||
# Update to a specific commit
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
|
||||
```
|
||||
|
||||
The update script:
|
||||
|
||||
1. Records current HEAD to `.last-deployed-sha`
|
||||
2. Fetches, resolves branch-or-commit, checks out
|
||||
3. `bun install`, builds daemon and agent
|
||||
4. Restarts daemon, waits, restarts agent
|
||||
5. Runs health check; reports failure and rollback instructions
|
||||
|
||||
## Rollback
|
||||
|
||||
```bash
|
||||
# Roll back to the previously deployed commit
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
|
||||
|
||||
# Roll back to a specific commit
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
|
||||
```
|
||||
|
||||
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that commit, rebuilds, and restarts services. The pre-rollback SHA is saved to `.pre-rollback-sha` for re-rollback if needed.
|
||||
|
||||
## Docker cleanup
|
||||
|
||||
```bash
|
||||
# Manual cleanup
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
||||
|
||||
# Check Docker disk usage
|
||||
docker system df
|
||||
|
||||
# Timer runs daily; check its schedule
|
||||
systemctl list-timers zopu-docker-cleanup.timer
|
||||
```
|
||||
|
||||
Cleanup prunes:
|
||||
|
||||
- Stopped containers older than 24 hours
|
||||
- Dangling (untagged) images
|
||||
- Unused networks
|
||||
|
||||
Named volumes and running containers are never removed.
|
||||
|
||||
## Disk-space monitoring
|
||||
|
||||
```bash
|
||||
# Manual check
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
|
||||
|
||||
# Custom threshold (90%)
|
||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
|
||||
```
|
||||
|
||||
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
|
||||
|
||||
## Firewall and private networking
|
||||
|
||||
The firewall (`ufw`) is deny-by-default:
|
||||
|
||||
- SSH (port 22) is allowed on all interfaces
|
||||
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
|
||||
- All other incoming traffic is denied
|
||||
|
||||
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
|
||||
|
||||
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` so all ports are reachable over the private overlay. Set `TAILSCALE_AUTHKEY` before running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent at `http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
|
||||
2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
|
||||
```bash
|
||||
ufw allow in on eth1 # or your private interface name
|
||||
```
|
||||
Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
|
||||
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in `caddy/` if you need TLS termination or a public ingress point.
|
||||
|
||||
## Environment groups
|
||||
|
||||
See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
|
||||
|
||||
| Group | Variables |
|
||||
| --- | --- |
|
||||
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
|
||||
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
|
||||
| Model gateway | `AGENT_MODEL_*` |
|
||||
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
|
||||
| Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
|
||||
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
|
||||
| Docker sandbox | group membership (no env vars) |
|
||||
| Service auth | `AUTH_SECRET` (if needed) |
|
||||
|
||||
## Public single-node routes
|
||||
|
||||
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
|
||||
|
||||
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
|
||||
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
|
||||
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
|
||||
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
|
||||
|
||||
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
--env-file /opt/zopu/.env \
|
||||
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
|
||||
exec backend ./generate_admin_key.sh
|
||||
```
|
||||
|
||||
## What is NOT deployed
|
||||
|
||||
- **Kubernetes**: no container orchestration.
|
||||
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
|
||||
- **Multi-node coordination**: single-node only.
|
||||
- **Public administration endpoints**: no admin HTTP surface.
|
||||
- **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
|
||||
- **Docker-backed Orb sandboxes**: Docker is installed and access is provisioned, but no Orb sandbox code is wired. This is a boundary prepared for the Orb lane, not a working feature.
|
||||
|
||||
## Reproducibility
|
||||
|
||||
The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
|
||||
|
||||
1. Services run under systemd with `Restart=always`.
|
||||
2. Logs persist in journald.
|
||||
3. Health checks run every 60 seconds via systemd timer.
|
||||
4. Docker cleanup runs daily.
|
||||
5. Disk usage is monitored daily.
|
||||
6. Unattended-upgrades handles Debian security patches.
|
||||
@@ -1,290 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# bootstrap.sh — One-shot installer for the Zopu single-node execution plane.
|
||||
#
|
||||
# Run as root on a fresh Debian 12 host:
|
||||
#
|
||||
# bash bootstrap.sh
|
||||
#
|
||||
# Environment overrides (set before running):
|
||||
# ZOPU_REPO_URL — SSH clone URL (default: ssh://git@git.openputer.com:2222/puter/zopu-code.git)
|
||||
# ZOPU_REPO_BRANCH — branch to deploy (default: dogfood/v0)
|
||||
# ZOPU_INSTALL_DIR — install path (default: /opt/zopu)
|
||||
# ZOPU_SERVICE_USER — system user (default: zopu)
|
||||
# TAILSCALE_AUTHKEY — if set, configure Tailscale
|
||||
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
|
||||
#
|
||||
# Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
|
||||
# web app, daemon, and agent, creates a non-root service user, installs systemd
|
||||
# units, and configures firewall/Tailscale defaults.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
REPO_URL="${ZOPU_REPO_URL:-ssh://git@git.openputer.com:2222/puter/zopu-code.git}"
|
||||
REPO_BRANCH="${ZOPU_REPO_BRANCH:-dogfood/v0}"
|
||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
||||
DEPLOY_DIR="${INSTALL_DIR}/deploy/zopu-runtime"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[bootstrap]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[bootstrap]${NC} $*"; }
|
||||
err() { echo -e "${RED}[bootstrap]${NC} $*" >&2; }
|
||||
|
||||
# runuser is part of util-linux (essential on Debian) and always available.
|
||||
# sudo is NOT assumed on minimal Debian installs.
|
||||
run_as_service() {
|
||||
runuser -u "$SERVICE_USER" -- "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
err "This script must be run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f /etc/debian_version ]]; then
|
||||
log "Detected Debian $(cat /etc/debian_version)"
|
||||
else
|
||||
warn "This script targets Debian 12. Other distributions may need manual adjustments."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. System packages
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Updating apt and installing base packages..."
|
||||
apt-get update -y
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
ufw \
|
||||
git \
|
||||
jq \
|
||||
netcat-openbsd \
|
||||
openssh-client \
|
||||
unattended-upgrades \
|
||||
rsyslog
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Docker Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v docker &>/dev/null; then
|
||||
log "Installing Docker Engine..."
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
||||
https://download.docker.com/linux/debian \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update -y
|
||||
apt-get install -y \
|
||||
docker-ce \
|
||||
docker-ce-cli \
|
||||
containerd.io \
|
||||
docker-buildx-plugin \
|
||||
docker-compose-plugin
|
||||
else
|
||||
log "Docker Engine already installed: $(docker --version)"
|
||||
fi
|
||||
|
||||
systemctl enable --now docker
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Node.js 22 (required by the Flue Node target)
|
||||
# ---------------------------------------------------------------------------
|
||||
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
|
||||
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
|
||||
log "Installing Node.js 22..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
|
||||
bash /tmp/nodesource_setup.sh
|
||||
apt-get install -y nodejs
|
||||
else
|
||||
log "Node.js already installed: $(node --version)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Bun
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v bun &>/dev/null; then
|
||||
log "Installing Bun..."
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
|
||||
else
|
||||
log "Bun already installed: $(bun --version)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Service user
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
log "Creating service user: $SERVICE_USER"
|
||||
useradd -r -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
if ! id -nG "$SERVICE_USER" | grep -qw docker; then
|
||||
usermod -aG docker "$SERVICE_USER"
|
||||
log "Added $SERVICE_USER to docker group"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Clone or update repository
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
log "Repository exists at $INSTALL_DIR, fetching latest..."
|
||||
cd "$INSTALL_DIR"
|
||||
git fetch origin
|
||||
git checkout "$REPO_BRANCH"
|
||||
git reset --hard "origin/$REPO_BRANCH"
|
||||
else
|
||||
log "Cloning $REPO_URL (branch $REPO_BRANCH) into $INSTALL_DIR..."
|
||||
git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6b. Hand ownership of the checkout to the service user
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Install dependencies and build
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Running bun install..."
|
||||
run_as_service bun install
|
||||
|
||||
log "Building web app..."
|
||||
run_as_service bun run --cwd apps/web build
|
||||
|
||||
log "Validating daemon production build..."
|
||||
run_as_service bun run build:daemon
|
||||
|
||||
log "Building agent service..."
|
||||
run_as_service bun run build:agents
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Environment file
|
||||
# ---------------------------------------------------------------------------
|
||||
ENV_FILE="$INSTALL_DIR/.env"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
log "Copying .env.template to .env — EDIT BEFORE STARTING SERVICES"
|
||||
cp "$DEPLOY_DIR/.env.template" "$ENV_FILE"
|
||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
warn "Edit $ENV_FILE with real values before starting services."
|
||||
else
|
||||
log ".env already exists at $ENV_FILE"
|
||||
# Ensure correct ownership and permissions on existing .env
|
||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Persistent log directory
|
||||
# ---------------------------------------------------------------------------
|
||||
LOG_DIR="/var/log/zopu"
|
||||
mkdir -p "$LOG_DIR"
|
||||
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Install systemd units (substitute placeholders)
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Installing systemd units..."
|
||||
for unit in zopu-web.service zopu-daemon.service zopu-agent.service \
|
||||
zopu-health.timer zopu-health.service \
|
||||
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
|
||||
SRC="$DEPLOY_DIR/systemd/$unit"
|
||||
DST="/etc/systemd/system/$unit"
|
||||
if [[ -f "$SRC" ]]; then
|
||||
sed \
|
||||
-e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
|
||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||
"$SRC" > "$DST"
|
||||
log " installed $unit"
|
||||
fi
|
||||
done
|
||||
systemctl daemon-reload
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Firewall (deny-by-default, explicit allow for Tailscale)
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Configuring firewall..."
|
||||
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
ufw allow 22/tcp
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
|
||||
# Allow all traffic on the Tailscale interface (if present)
|
||||
# This lets the agent and engine ports be reached over the private overlay.
|
||||
ufw allow in on tailscale0 || warn "tailscale0 not present yet; rule will activate when interface appears"
|
||||
|
||||
ufw --force enable
|
||||
log "Firewall enabled: SSH (22) allowed, tailscale0 allowed."
|
||||
warn "Agent and RivetKit ports are NOT exposed on public interfaces."
|
||||
warn "Reachability is via Tailscale (tailscale0) only."
|
||||
else
|
||||
log "Firewall already active. Ensuring tailscale0 rule..."
|
||||
ufw allow in on tailscale0 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. Tailscale (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
|
||||
log "Installing and configuring Tailscale..."
|
||||
if ! command -v tailscaled &>/dev/null; then
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
fi
|
||||
tailscale up --authkey "$TAILSCALE_AUTHKEY" \
|
||||
--hostname "${TAILSCALE_HOSTNAME:-zopu-runtime}" \
|
||||
--accept-routes
|
||||
log "Tailscale configured: $(tailscale ip -4 2>/dev/null || echo 'waiting for IP')"
|
||||
|
||||
# Re-apply the tailscale0 firewall rule now that the interface exists
|
||||
ufw allow in on tailscale0 2>/dev/null || true
|
||||
else
|
||||
warn "TAILSCALE_AUTHKEY not set — skipping Tailscale setup."
|
||||
warn "Without Tailscale, services are reachable only via localhost."
|
||||
warn "To use a private network interface, add an explicit UFW rule:"
|
||||
warn " ufw allow in on <interface>"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13. Disk-space monitoring cron
|
||||
# /etc/cron.d format REQUIRES a username field.
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Installing disk-space monitor (daily at 06:00)..."
|
||||
CRON_LINE="0 6 * * * ${SERVICE_USER} ${DEPLOY_DIR}/scripts/disk-monitor.sh --warn-percent 80 >> /var/log/zopu/disk-monitor.log 2>&1"
|
||||
echo "$CRON_LINE" > /etc/cron.d/zopu-disk-monitor
|
||||
chmod 644 /etc/cron.d/zopu-disk-monitor
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Done
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Bootstrap complete."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit $ENV_FILE with real values"
|
||||
echo " 2. Start services:"
|
||||
echo " systemctl start zopu-web zopu-daemon zopu-agent"
|
||||
echo " 3. Enable health monitoring:"
|
||||
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
|
||||
echo " 4. Verify health:"
|
||||
echo " $DEPLOY_DIR/scripts/health-check.sh"
|
||||
echo ""
|
||||
warn "Services are NOT started automatically. Edit .env first."
|
||||
@@ -1,23 +0,0 @@
|
||||
# Caddyfile — Public reverse proxy for the Zopu web + API.
|
||||
#
|
||||
# The web frontend (port 5173) is served at the root, Better Auth is proxied
|
||||
# first-party under /api/auth, and Flue is mounted under /api/flue.
|
||||
|
||||
zopu.cheaptricks.puter.wtf {
|
||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
||||
encode zstd gzip
|
||||
|
||||
handle /api/auth/* {
|
||||
reverse_proxy https://befitting-dalmatian-161.convex.site {
|
||||
header_up Host befitting-dalmatian-161.convex.site
|
||||
header_up X-Forwarded-Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /api/flue/* {
|
||||
reverse_proxy 127.0.0.1:3585
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:5173
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
admin 127.0.0.1:2019
|
||||
auto_https off
|
||||
http_port 8080
|
||||
}
|
||||
|
||||
http://zopu.sai-onchain.me {
|
||||
bind 127.0.0.1
|
||||
reverse_proxy 127.0.0.1:13100
|
||||
}
|
||||
|
||||
http://zopu-api.sai-onchain.me {
|
||||
bind 127.0.0.1
|
||||
reverse_proxy 127.0.0.1:3210
|
||||
}
|
||||
|
||||
http://zopu-site.sai-onchain.me {
|
||||
bind 127.0.0.1
|
||||
reverse_proxy 127.0.0.1:3211
|
||||
}
|
||||
|
||||
http://zopu-agent.sai-onchain.me {
|
||||
bind 127.0.0.1
|
||||
reverse_proxy 127.0.0.1:3583
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
|
||||
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
|
||||
|
||||
ingress:
|
||||
- hostname: zopu.sai-onchain.me
|
||||
service: http://127.0.0.1:8080
|
||||
- hostname: zopu-api.sai-onchain.me
|
||||
service: http://127.0.0.1:8080
|
||||
- hostname: zopu-site.sai-onchain.me
|
||||
service: http://127.0.0.1:8080
|
||||
- hostname: zopu-agent.sai-onchain.me
|
||||
service: http://127.0.0.1:8080
|
||||
- service: http_status:404
|
||||
@@ -1,32 +0,0 @@
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/get-convex/convex-backend:latest
|
||||
container_name: zopu-convex
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 10s
|
||||
stop_signal: SIGINT
|
||||
ports:
|
||||
- "127.0.0.1:3210:3210"
|
||||
- "127.0.0.1:3211:3211"
|
||||
volumes:
|
||||
- zopu-convex-data:/convex/data
|
||||
environment:
|
||||
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
|
||||
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
|
||||
DISABLE_BEACON: "true"
|
||||
DISABLE_METRICS_ENDPOINT: "true"
|
||||
DOCUMENT_RETENTION_DELAY: "172800"
|
||||
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
|
||||
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
|
||||
REDACT_LOGS_TO_CLIENT: "true"
|
||||
RUST_LOG: ${CONVEX_RUST_LOG:-info}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
zopu-convex-data:
|
||||
name: zopu-convex-data
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# disk-monitor.sh — Check disk usage and alert when above threshold.
|
||||
#
|
||||
# Usage:
|
||||
# disk-monitor.sh # print usage, warn at 80%
|
||||
# disk-monitor.sh --warn-percent 90 # custom threshold
|
||||
#
|
||||
# Requires GNU coreutils df (standard on Debian). Uses --output for
|
||||
# deterministic column ordering regardless of locale.
|
||||
#
|
||||
# Exit code 0 if under threshold, 1 if at or above.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WARN_PERCENT=80
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--warn-percent)
|
||||
WARN_PERCENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
# Partitions to check: install root and /var (Docker data-root is often here)
|
||||
PARTITIONS="${PARTITIONS:-/ /var}"
|
||||
|
||||
for partition in $PARTITIONS; do
|
||||
if [[ ! -d "$partition" ]]; then
|
||||
continue
|
||||
fi
|
||||
# GNU df --output columns: pcent (Use%), size, avail, target (Mounted on)
|
||||
read -r USAGE_PCT SIZE AVAIL MOUNT <<< "$(df -h --output=pcent,size,avail,target "$partition" | awk 'NR==2 {gsub(/%/,"",$1); print $1, $2, $3, $4}')"
|
||||
|
||||
if [[ -z "${USAGE_PCT:-}" || ! "${USAGE_PCT:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo " [skip] Could not read usage for ${partition}"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$USAGE_PCT" -ge "$WARN_PERCENT" ]]; then
|
||||
echo -e " ${RED}WARN${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — threshold ${WARN_PERCENT}%"
|
||||
EXIT_CODE=1
|
||||
elif [[ "$USAGE_PCT" -ge $((WARN_PERCENT - 10)) ]]; then
|
||||
echo -e " ${YELLOW}NOTE${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — approaching threshold"
|
||||
else
|
||||
echo -e " ${GREEN}OK${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE})"
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# docker-cleanup.sh — Remove stopped containers, dangling images, and unused networks.
|
||||
#
|
||||
# Safe to run via systemd timer (daily). Uses Docker's built-in pruning
|
||||
# commands with conservative scope.
|
||||
#
|
||||
# Does NOT prune volumes. Named volumes may hold durable data and cannot be
|
||||
# safely auto-pruned in a deployment that mixes stateful workloads. When the
|
||||
# Orb lane lands with labeled resources, volume pruning can be scoped to
|
||||
# Orb-managed labels (e.g. --filter label=org.openputer.orb). Until then,
|
||||
# manage volumes manually.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "[docker-cleanup] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# Remove stopped containers older than 24 hours
|
||||
echo "[docker-cleanup] Pruning stopped containers (>24h old)..."
|
||||
docker container prune -f --filter "until=24h"
|
||||
|
||||
# Remove dangling images (untagged intermediate layers only)
|
||||
echo "[docker-cleanup] Pruning dangling images..."
|
||||
docker image prune -f
|
||||
|
||||
# Remove unused networks
|
||||
echo "[docker-cleanup] Pruning unused networks..."
|
||||
docker network prune -f
|
||||
|
||||
# Volumes are intentionally NOT pruned. See header comment.
|
||||
|
||||
# Show remaining disk usage
|
||||
echo "[docker-cleanup] Docker disk usage:"
|
||||
docker system df
|
||||
|
||||
echo "[docker-cleanup] Done."
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# health-check.sh — Probe the Zopu execution plane.
|
||||
#
|
||||
# Checks (TCP/process-level; no assumed HTTP health routes):
|
||||
# 1. zopu-daemon systemd unit is active
|
||||
# 2. zopu-agent systemd unit is active
|
||||
# 3. RivetKit engine TCP port (RIVET_ENDPOINT, default 6420) accepts connections
|
||||
# 4. Flue agent TCP port (PORT, default 3583) accepts connections
|
||||
# 5. Docker daemon is reachable
|
||||
#
|
||||
# Flue does not expose a health endpoint by design. RivetKit's health route
|
||||
# is internal to the registry runtime. We use TCP connection checks only.
|
||||
#
|
||||
# Usage:
|
||||
# health-check.sh # print results
|
||||
# health-check.sh --quiet # suppress output, exit 0 only if all healthy
|
||||
#
|
||||
# Exit codes: 0 = all healthy, 1 = one or more unhealthy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QUIET=false
|
||||
[[ "${1:-}" == "--quiet" ]] && QUIET=true
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { $QUIET || echo -e " ${GREEN}PASS${NC} $*"; }
|
||||
fail() { echo -e " ${RED}FAIL${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
|
||||
|
||||
FAILURES=0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load environment
|
||||
# ---------------------------------------------------------------------------
|
||||
ENV_FILE="${ENV_FILE:-/opt/zopu/.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
RIVET_PORT="6420"
|
||||
if [[ -n "${RIVET_ENDPOINT:-}" ]]; then
|
||||
RIVET_PORT=$(echo "$RIVET_ENDPOINT" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')
|
||||
[[ -z "$RIVET_PORT" ]] && RIVET_PORT="6420"
|
||||
fi
|
||||
|
||||
AGENT_PORT="${PORT:-3583}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP probe helper: works on Debian (nc from netcat-openbsd) and macOS.
|
||||
# Falls back to bash /dev/tcp if nc is unavailable.
|
||||
# ---------------------------------------------------------------------------
|
||||
tcp_probe() {
|
||||
local host="$1" port="$2"
|
||||
if command -v nc &>/dev/null; then
|
||||
nc -z -w 5 "$host" "$port" 2>/dev/null
|
||||
else
|
||||
timeout 5 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Daemon systemd unit
|
||||
# ---------------------------------------------------------------------------
|
||||
if systemctl is-active --quiet zopu-daemon 2>/dev/null; then
|
||||
pass "zopu-daemon service is active"
|
||||
else
|
||||
fail "zopu-daemon service is not active"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Agent systemd unit
|
||||
# ---------------------------------------------------------------------------
|
||||
if systemctl is-active --quiet zopu-agent 2>/dev/null; then
|
||||
pass "zopu-agent service is active"
|
||||
else
|
||||
fail "zopu-agent service is not active"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. RivetKit engine TCP port
|
||||
# ---------------------------------------------------------------------------
|
||||
if tcp_probe localhost "$RIVET_PORT"; then
|
||||
pass "RivetKit engine port ${RIVET_PORT} is accepting connections"
|
||||
else
|
||||
fail "RivetKit engine port ${RIVET_PORT} is not accepting connections"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Flue agent TCP port
|
||||
# ---------------------------------------------------------------------------
|
||||
if tcp_probe localhost "$AGENT_PORT"; then
|
||||
pass "Flue agent port ${AGENT_PORT} is accepting connections"
|
||||
else
|
||||
fail "Flue agent port ${AGENT_PORT} is not accepting connections"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Docker daemon
|
||||
# ---------------------------------------------------------------------------
|
||||
if docker info &>/dev/null; then
|
||||
pass "Docker daemon is reachable"
|
||||
else
|
||||
fail "Docker daemon is not reachable"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
$QUIET || echo ""
|
||||
if [[ "$FAILURES" -eq 0 ]]; then
|
||||
$QUIET || echo -e "${GREEN}All checks passed.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}${FAILURES} check(s) failed.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# rollback.sh — Roll back to the previously deployed commit.
|
||||
#
|
||||
# Usage:
|
||||
# rollback.sh # roll back to .last-deployed-sha
|
||||
# rollback.sh <sha> # roll back to a specific commit
|
||||
#
|
||||
# .env is gitignored and is never touched by git operations. It survives
|
||||
# updates and rollbacks unchanged.
|
||||
#
|
||||
# Must be run as root (uses runuser to build as the service user).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[rollback]${NC} $*"; }
|
||||
err() { echo -e "${RED}[rollback]${NC} $*" >&2; }
|
||||
|
||||
run_as_service() {
|
||||
runuser -u "$SERVICE_USER" -- "$@"
|
||||
}
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# Determine target
|
||||
ROLLBACK_SHA="${1:-}"
|
||||
if [[ -z "$ROLLBACK_SHA" ]]; then
|
||||
LAST_SHA_FILE="${INSTALL_DIR}/.last-deployed-sha"
|
||||
if [[ ! -f "$LAST_SHA_FILE" ]]; then
|
||||
err "No previous deployment recorded in ${LAST_SHA_FILE}."
|
||||
err "Pass a commit SHA explicitly: rollback.sh <sha>"
|
||||
exit 1
|
||||
fi
|
||||
ROLLBACK_SHA=$(cat "$LAST_SHA_FILE")
|
||||
fi
|
||||
|
||||
# Validate commit exists
|
||||
if ! git rev-parse --verify "${ROLLBACK_SHA}^{commit}" &>/dev/null; then
|
||||
err "Commit ${ROLLBACK_SHA} does not exist in the local repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_SHA=$(git rev-parse HEAD)
|
||||
log "Current: ${CURRENT_SHA:0:12}"
|
||||
log "Rolling back to: ${ROLLBACK_SHA:0:12}"
|
||||
|
||||
# Save current state before rolling back (enables re-rollback)
|
||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.pre-rollback-sha"
|
||||
|
||||
# Checkout target
|
||||
git checkout "$ROLLBACK_SHA"
|
||||
|
||||
# Restore ownership of the checkout to the service user after git operations
|
||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
# Confirm .env is intact and has correct permissions
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
log ".env preserved."
|
||||
chmod 600 "${INSTALL_DIR}/.env"
|
||||
else
|
||||
err ".env is missing! Restore it from backup before starting services."
|
||||
fi
|
||||
|
||||
log "Running bun install..."
|
||||
run_as_service bun install
|
||||
|
||||
log "Building web app..."
|
||||
run_as_service bun run --cwd apps/web build
|
||||
|
||||
log "Validating daemon production build..."
|
||||
run_as_service bun run build:daemon
|
||||
|
||||
log "Building agent service..."
|
||||
run_as_service bun run build:agents
|
||||
|
||||
log "Restarting services..."
|
||||
systemctl restart zopu-daemon
|
||||
sleep 3
|
||||
systemctl restart zopu-agent
|
||||
systemctl restart zopu-web
|
||||
sleep 5
|
||||
|
||||
# Health check
|
||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
||||
log "Running health check..."
|
||||
if "$HEALTH_SCRIPT"; then
|
||||
log "Rollback complete and healthy."
|
||||
else
|
||||
err "Health check failed after rollback!"
|
||||
err " journalctl -u zopu-daemon -n 50"
|
||||
err " journalctl -u zopu-agent -n 50"
|
||||
err " journalctl -u zopu-web -n 50"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# update.sh — Update the Zopu runtime to a specific branch or commit.
|
||||
#
|
||||
# Usage:
|
||||
# update.sh # update to latest dogfood/v0
|
||||
# update.sh dogfood/v0 # update to latest of branch dogfood/v0
|
||||
# update.sh <commit-sha> # checkout and build a specific commit
|
||||
#
|
||||
# The argument is treated as a branch name first; if no matching remote
|
||||
# tracking branch exists it is treated as a commit SHA.
|
||||
#
|
||||
# .env is gitignored and is never touched by git operations. It survives
|
||||
# updates and rollbacks unchanged.
|
||||
#
|
||||
# Must be run as root (uses runuser to build as the service user).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
||||
TARGET="${1:-dogfood/v0}"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[update]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[update]${NC} $*"; }
|
||||
err() { echo -e "${RED}[update]${NC} $*" >&2; }
|
||||
|
||||
run_as_service() {
|
||||
runuser -u "$SERVICE_USER" -- "$@"
|
||||
}
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# Record current commit for rollback
|
||||
CURRENT_SHA=$(git rev-parse HEAD)
|
||||
log "Current HEAD: ${CURRENT_SHA:0:12}"
|
||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.last-deployed-sha"
|
||||
|
||||
# Fetch all remotes
|
||||
log "Fetching from origin..."
|
||||
git fetch origin
|
||||
|
||||
# Resolve target: branch first, then commit
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET}"; then
|
||||
log "Target is a branch: ${TARGET}"
|
||||
git checkout -B "$TARGET" "origin/${TARGET}"
|
||||
elif git rev-parse --verify "${TARGET}^{commit}" &>/dev/null; then
|
||||
log "Target is a commit: ${TARGET:0:12}"
|
||||
git checkout "$TARGET"
|
||||
else
|
||||
err "'${TARGET}' is not a known branch or valid commit."
|
||||
err "Available branches: $(git branch -r | tr '\n' ' ')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_SHA=$(git rev-parse HEAD)
|
||||
log "Now at: ${NEW_SHA:0:12}"
|
||||
|
||||
# Restore ownership of the checkout to the service user after git operations
|
||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
# Confirm .env is intact and has correct permissions
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
log ".env preserved."
|
||||
chmod 600 "${INSTALL_DIR}/.env"
|
||||
else
|
||||
err ".env is missing! Restore it from backup before starting services."
|
||||
fi
|
||||
|
||||
# Install and build
|
||||
log "Running bun install..."
|
||||
run_as_service bun install
|
||||
|
||||
log "Building web app..."
|
||||
run_as_service bun run --cwd apps/web build
|
||||
|
||||
log "Validating daemon production build..."
|
||||
run_as_service bun run build:daemon
|
||||
|
||||
log "Building agent service..."
|
||||
run_as_service bun run build:agents
|
||||
|
||||
# Graceful restart: daemon first (owns RivetKit engine), then agent and web
|
||||
log "Restarting services..."
|
||||
systemctl restart zopu-daemon
|
||||
sleep 3
|
||||
systemctl restart zopu-agent
|
||||
systemctl restart zopu-web
|
||||
sleep 5
|
||||
|
||||
# Health check
|
||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
||||
log "Running health check..."
|
||||
if "$HEALTH_SCRIPT"; then
|
||||
log "Update complete and healthy."
|
||||
else
|
||||
err "Health check failed after update!"
|
||||
err " journalctl -u zopu-daemon -n 50"
|
||||
err " journalctl -u zopu-agent -n 50"
|
||||
err " journalctl -u zopu-web -n 50"
|
||||
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "Update complete. Verify manually."
|
||||
fi
|
||||
@@ -1,40 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Agent Service (Flue Node server)
|
||||
After=network-online.target zopu-daemon.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__SERVICE_USER__
|
||||
Group=__SERVICE_USER__
|
||||
WorkingDirectory=__INSTALL_DIR__/packages/agents
|
||||
|
||||
EnvironmentFile=__INSTALL_DIR__/.env
|
||||
Environment=PORT=3583
|
||||
|
||||
# Flue's Node target requires Node built-ins such as node:sqlite.
|
||||
# Listens on the service-pinned port 3583.
|
||||
ExecStart=/usr/bin/node dist/server.mjs
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=zopu-agent
|
||||
|
||||
# Resource limits
|
||||
MemoryMax=8G
|
||||
CPUWeight=80
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Docker socket access for Orb sandbox runtime (lives in agent process)
|
||||
SupplementaryGroups=docker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,42 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Daemon (Bun/Effect + AgentOS/RivetKit)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__SERVICE_USER__
|
||||
Group=__SERVICE_USER__
|
||||
WorkingDirectory=__INSTALL_DIR__
|
||||
|
||||
EnvironmentFile=__INSTALL_DIR__/.env
|
||||
Environment=HOME=/var/lib/zopu
|
||||
StateDirectory=zopu
|
||||
|
||||
# RivetKit in-process engine: envoy mode (serverful).
|
||||
# Run the Bun source entrypoint so AgentOS software assets retain their real
|
||||
# node_modules paths; Bun compiled executables do not embed .aospkg files.
|
||||
ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=zopu-daemon
|
||||
|
||||
# Resource limits (12-core, 40 GB host)
|
||||
MemoryMax=8G
|
||||
CPUWeight=100
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Docker socket access (for future Orb sandboxes; group membership required)
|
||||
SupplementaryGroups=docker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,7 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Docker Cleanup
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
||||
@@ -1,9 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Docker Cleanup (daily)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Health Check
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=__SERVICE_USER__
|
||||
EnvironmentFile=__INSTALL_DIR__/.env
|
||||
# health-check.sh reads ENV_FILE to find the .env to source. Set it to the
|
||||
# install path so custom install dirs work correctly.
|
||||
Environment=ENV_FILE=__INSTALL_DIR__/.env
|
||||
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Health Check (every 60s)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=60
|
||||
AccuracySec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,26 +0,0 @@
|
||||
[Unit]
|
||||
Description=Zopu Web
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__SERVICE_USER__
|
||||
Group=__SERVICE_USER__
|
||||
WorkingDirectory=__INSTALL_DIR__/apps/web
|
||||
EnvironmentFile=__INSTALL_DIR__/.env
|
||||
Environment=HOST=127.0.0.1
|
||||
Environment=PORT=13100
|
||||
ExecStart=/usr/local/bin/bun run start
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=zopu-web
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
162
docs/DEPLOYMENT_PLAN.md
Normal file
162
docs/DEPLOYMENT_PLAN.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Deployment Architecture Recommendation
|
||||
|
||||
> **Status:** Partially implemented — staging environment is live as of 2026-08-03. The public Vercel/Convex endpoints and VDS services are operational; the declarative Pulumi/Ansible/CI backlog remains.
|
||||
>
|
||||
> **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 + AgentOS registry (one process) Web SSR Agents VDS
|
||||
│ │ │
|
||||
personal Convex dev deployment staging Convex deployment
|
||||
```
|
||||
|
||||
| Concern | Iteration | Staging |
|
||||
| --- | --- | --- |
|
||||
| User-visible URL | Existing Tailscale URL on the Mac | `https://zopu-staging.vercel.app` 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 + AgentOS registry (one Node process) + Engine | Contabo VDS: one Flue + AgentOS registry Node process + Rivet Engine |
|
||||
| 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.
|
||||
3. **Flue + in-process AgentOS registry + Rivet Engine** are the worker topology. Flue owns the registry request handler in the same Node process; the Engine remains a separate private service with durable state. Their filesystem-backed workspaces require a long-lived volume, so they are not appropriate for Vercel or Cloudflare Workers. [Flue Node deployment](https://flueframework.com/docs/ecosystem/deploy/node) · [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.
|
||||
|
||||
Create these independent Convex deployments:
|
||||
|
||||
| Deployment | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `dev:<developer>` | Convex dev | Local loop only; each developer owns one |
|
||||
| `staging` (`joyous-cat-297`) | 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 environment’s Flue worker (e.g. `https://agents.zopu.puter.wtf`);
|
||||
- `FLUE_DB_TOKEN` shared only with that environment’s 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://zopu-staging.vercel.app/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: zopu-staging.vercel.app]
|
||||
Browser -->|queries & mutations| Convex[Convex: joyous-cat-297]
|
||||
Vercel -->|/api/auth rewrite| ConvexSite[Convex Site: joyous-cat-297.convex.site]
|
||||
Convex -->|FLUE_DB_TOKEN + org header| Flue[Contabo: Flue + AgentOS registry one process]
|
||||
Flue -->|control plane + outbound envoy| Engine[Rivet Engine: 127.0.0.1:6420]
|
||||
Engine --> Flue
|
||||
Engine --> Volumes[/srv/zopu/data/rivet]
|
||||
Flue --> Workspaces[/srv/zopu/workspaces + /srv/zopu/data/flue]
|
||||
CF[Cloudflare DNS] --> Vercel
|
||||
CF --> Flue
|
||||
```
|
||||
|
||||
### Public surface
|
||||
|
||||
- `zopu-staging.vercel.app` → Vercel.
|
||||
- `agents.zopu.puter.wtf` → Contabo VDS (`zopu-staging-1`, `158.220.110.74`) Caddy → Flue only.
|
||||
- Only ports 80/443 (and Tailscale/managed SSH) enter the VDS. Do **not** publish Rivet ports `6420` or `6421`, registry internals, 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 current VDS (`zopu-staging-1`, `158.220.110.74`) runs a deliberately small private topology:
|
||||
|
||||
| Service | Runtime | Network exposure | Persistent data | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `engine` | Existing Docker Compose (`/srv/zopu/compose/docker-compose.yml`) | private loopback `127.0.0.1:6420` | `/srv/zopu/data/rivet` bind mount | Single-node RocksDB backend. Health: `http://127.0.0.1:6420/health`. |
|
||||
| `agents` | systemd (`/etc/systemd/system/zopu-agents.service`) | Caddy upstream only (`127.0.0.1:3000`) | `/srv/zopu/workspaces` + `/srv/zopu/data/flue` | One Node process runs Flue **and** hosts the AgentOS registry in native envoy Mode A (`registry.startAndWait()`). `RIVET_ENDPOINT` is the Engine control plane; the registry opens an outbound connection to the Engine, which routes actor calls back over it. No second runner process and no inbound serverless callback route. |
|
||||
| `caddy` | systemd (`/etc/systemd/system/caddy.service`) | 80/443 only | Caddy certificate/config volumes | `/etc/caddy/Caddyfile` terminates TLS for `agents.zopu.puter.wtf` and forwards only the documented Flue paths to `127.0.0.1:3000`. |
|
||||
|
||||
Release artifacts are source controlled: `scripts/release-agents.sh` builds a curated bundle locally and atomically promotes `/srv/zopu/current`; `deploy/vds/` contains the systemd unit and secret-free environment template. The VDS materializes Linux production dependencies from the frozen lockfile, so macOS-native binaries are never uploaded.
|
||||
|
||||
Rivet’s 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)
|
||||
|
||||
## IaC model: Pulumi + Ansible + systemd/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 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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/vds` + `/srv/zopu/compose` | **systemd + Docker Compose** | Engine stays in its existing private Compose service; one checked-in systemd unit runs the single Flue + AgentOS registry Node process (native envoy Mode A). |
|
||||
| Release execution | `scripts/release-agents.sh` now; CI later | **Local release bundle promotion** | Builds a curated cross-platform bundle, installs Linux dependencies on the VDS, atomically swaps `current`, then restarts `zopu-agents`. |
|
||||
|
||||
> **Current state (2026-08-03):** the Engine and Caddy retain their manually bootstrapped VDS services. The Node agents process is now represented by source-controlled `deploy/vds` artifacts and `scripts/release-agents.sh`; Pulumi/Ansible and CI are still future work.
|
||||
|
||||
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. Pulumi’s 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 Mac’s 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. Run repository validation and build the staging web artifact for the staging Convex deployment (`joyous-cat-297`, `https://joyous-cat-297.convex.cloud`), then deploy it to `zopu-staging` (`https://zopu-staging.vercel.app`).
|
||||
2. From the repository root, create and promote the agents release with `scripts/release-agents.sh <vds-host> <release-id>`; it installs Linux production dependencies on the VDS, atomically swaps `/srv/zopu/current`, and leaves the running process untouched until restart.
|
||||
3. Restart `zopu-agents.service` on the VDS. The unit waits for Engine health, then starts the single Node process that serves Flue and starts the AgentOS registry in native envoy Mode A.
|
||||
4. Verify: `https://zopu-staging.vercel.app` returns 200, Convex auth origin `https://zopu-staging.vercel.app` is accepted, `https://agents.zopu.puter.wtf/health` returns 200, Engine health returns 200 at `http://127.0.0.1:6420/health` inside the private network, `zopu-agents.service` is active, the registry envoy connected to the engine (check the process log for the startup line), and a real signed-in staging conversation receives an agent response.
|
||||
5. Roll back by atomically repointing `/srv/zopu/current` at the prior release then restarting `zopu-agents.service`.
|
||||
|
||||
## Required implementation backlog
|
||||
|
||||
The staging environment is live. Remaining work is to close the gap between the manual, on-host configuration and the declarative, CI-driven target described above:
|
||||
|
||||
1. **Convex staging deployment** — ✅ Done as `joyous-cat-297`. `SITE_URL` is set to `https://zopu-staging.vercel.app`, `FLUE_URL`/`AGENT_BACKEND_URL` to `https://agents.zopu.puter.wtf`, and `FLUE_DB_TOKEN` to the VDS agents token.
|
||||
2. **Auth/webhook origin seams** — ✅ Vercel rewrites `/api/auth/*` to `https://joyous-cat-297.convex.site/api/auth/*` in `vercel.json`. Verify that Puter/Gitea webhooks are configured to post directly to the Convex Site HTTP action URL (`https://joyous-cat-297.convex.site/api/git/webhooks/...`) rather than the web origin.
|
||||
3. **Vercel React Router staging project** — ✅ Done as `zopu-staging` (`https://zopu-staging.vercel.app`).
|
||||
4. **VDS agent topology** — ✅ Source-controlled VDS unit, env template, and atomic release bundle now model the single Flue + AgentOS registry Node process running in native envoy Mode A. Apply them to the VDS and retire the former separate runner service.
|
||||
5. **Engine deployment, backups, and CI** — 🔄 Engine health and VDS backups exist. Move the existing Engine Compose service and Caddy configuration into source-controlled provisioning; add CI to run validation, promote release bundles, and deploy the web.
|
||||
6. **Declarative IaC** — ⏳ Create `infra/pulumi` (Cloudflare DNS + Vercel project) and `infra/ansible` (host convergence).
|
||||
7. **End-to-end staging smoke** — ⏳ Execute: sign in via GitHub OAuth, create/connect a project, send a conversation, and complete a disposable issue-to-PR job against the live `zopu-staging` + `agents.zopu.puter.wtf` stack.
|
||||
|
||||
## 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.
|
||||
- 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 + AgentOS registry Node instance (native envoy Mode A) 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/LOCAL_SETUP.md`; `docs/auth-proxy.md`; `vercel.json`; `apps/web/Dockerfile`; `deploy/vds/`; `scripts/release-agents.sh`.
|
||||
- [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 [runtime modes](https://rivet.dev/docs/general/runtime-modes).
|
||||
- [Flue Node deployment](https://flueframework.com/docs/ecosystem/deploy/node) 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/).
|
||||
289
docs/LOCAL_SETUP.md
Normal file
289
docs/LOCAL_SETUP.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 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 + AgentOS registry (:3585, one Node process)
|
||||
-> Rivet Engine control plane (:6420)
|
||||
-> AgentOS workspace actor (Git + repo clone mounted from the local checkout)
|
||||
-> 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
|
||||
# The private Engine control plane the registry connects to.
|
||||
RIVET_ENDPOINT=http://127.0.0.1:6420
|
||||
RIVET_WORKSPACE_TOKEN=<shared-random-workspace-token>
|
||||
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
|
||||
# Optional: bump when the actor definition changes to drain old actors.
|
||||
RIVET_ENVOY_VERSION=0.0.0
|
||||
```
|
||||
|
||||
The Hono/Flue Node process hosts the AgentOS registry in **native envoy Mode A** (`registry.startAndWait()`). The registry opens one persistent outbound connection to the Rivet Engine; the Engine routes actor calls back over that connection, so no inbound HTTP callback is required. Port `6421` is an internal Engine API-peer port and must not be used as `RIVET_ENDPOINT`. Do not set `RIVET_SERVERLESS_URL` or `RIVET_SERVERLESS_TOKEN`; they are unused in this topology.
|
||||
|
||||
### 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 Engine must be healthy before the agents process starts.
|
||||
|
||||
### 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 Rivet Engine
|
||||
|
||||
Start exactly one Engine listening on `127.0.0.1:6420` using the local Engine deployment mechanism. Do not use port `6421` as the application endpoint.
|
||||
|
||||
### 3. Start the agents process
|
||||
|
||||
```bash
|
||||
# Laptop-only access
|
||||
pnpm --filter @code/agents dev -- --port 3585
|
||||
|
||||
# Convex callbacks or access from another device
|
||||
HOST=0.0.0.0 \
|
||||
RIVET_ENDPOINT=http://127.0.0.1:6420 \
|
||||
FLUE_URL=http://<tailscale-ip>:3585 \
|
||||
pnpm --filter @code/agents dev -- --port 3585
|
||||
```
|
||||
|
||||
This single process serves the Hono/Flue routes **and** starts the AgentOS registry in native envoy Mode A during app module initialization (`registry.startAndWait()`). The registry opens an outbound connection to the Rivet Engine; actor calls are routed back over that connection, so there is no separate runner process and no inbound serverless callback route. If shell exports override `.env`, set `RIVET_ENDPOINT` explicitly.
|
||||
|
||||
### 4. 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 -fsS http://127.0.0.1:6420/health
|
||||
curl -fsS http://127.0.0.1:3585/health
|
||||
curl -I http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
The first two commands must return a successful health response; the web root may redirect or return `404` if its 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 AgentOS actor through the Rivet Engine
|
||||
-> a Rivet actor boots an AgentOS VM with the project workspace
|
||||
-> the actor clones the repository and runs validated commands
|
||||
-> the actor writes artifacts back through the actor RPC
|
||||
```
|
||||
|
||||
## 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 the Engine is healthy at `http://127.0.0.1:6420/health`.
|
||||
- Confirm the agents process is running and the registry started the envoy connection on startup.
|
||||
- Confirm the process has `RIVET_ENDPOINT=http://127.0.0.1:6420`.
|
||||
- Confirm `AGENT_WORKSPACE_ROOT` is writable.
|
||||
- Restart the agents process after changing the actor configuration; the registry re-registers actor type changes with the engine on startup.
|
||||
|
||||
The actor client requires CBOR encoding for actor RPC. Do not remove the `encoding: "cbor"` configuration in `packages/agents/src/adapters/agentos.ts`.
|
||||
|
||||
### AgentOS reports an ACP completed-message resource limit
|
||||
|
||||
Restart the agents process so the registry re-registers its updated actor configuration with the engine.
|
||||
|
||||
### The process connects to a remote Rivet deployment unexpectedly
|
||||
|
||||
Environment variables exported by the shell override values loaded from `.env`. Start the agents process with explicit `RIVET_ENDPOINT=http://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:
|
||||
|
||||
- [`DEPLOYMENT_PLAN.md`](DEPLOYMENT_PLAN.md) for the staging topology and VDS release path
|
||||
- [Rivet AgentOS quickstart](https://rivet.dev/docs/agent-os/quickstart)
|
||||
- [Flue Node deployment](https://flueframework.com/docs/ecosystem/deploy/node)
|
||||
@@ -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
|
||||
→ approved Work Definition
|
||||
→ ready 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;
|
||||
- approve definition/design;
|
||||
- definition/design persist automatically;
|
||||
- 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 → policy-defined approvals
|
||||
Human → merge/release authorization
|
||||
```
|
||||
|
||||
### Risk lanes
|
||||
@@ -309,7 +309,7 @@ definition → combined design → 1–3 slices → verify → review → PR
|
||||
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
|
||||
|
||||
```text
|
||||
definition approval → architecture approval → program-design approval
|
||||
definition → design → ready → merge/release
|
||||
→ 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 approval;
|
||||
- Design Packet approval for standard/critical work;
|
||||
- Work Definition (no approval gate);
|
||||
- Design Packet (no approval gate);
|
||||
- slice review when risk requires it;
|
||||
- merge/release approval;
|
||||
- resolution of ambiguity or policy exceptions.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Zopu Work OS — Documentation Map
|
||||
|
||||
Dense context set for product development and coding agents.
|
||||
|
||||
## Read order
|
||||
|
||||
```text
|
||||
1. agent-context.md — bootstrap and non-negotiables
|
||||
2. product.md — product model and quality doctrine
|
||||
3. glossary.md — canonical terminology
|
||||
4. dev-loop.md — normative software delivery process
|
||||
5. tech.md — architecture, actors, ports, runtimes
|
||||
6. design.md — user experience
|
||||
7. slices.md — sequential vertical product increments
|
||||
8. dev-plan.md — repository execution plan/backlog
|
||||
9. evaluation.md — quality measurement and improvement
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| Verifier | agent-context, dev-loop, evaluation, Verification Plan |
|
||||
| Frontend agent | agent-context, design, product, current slice |
|
||||
| Lead/orchestrator | all documents, current repository state, active decisions |
|
||||
|
||||
## Document boundaries
|
||||
|
||||
```text
|
||||
product.md what/why
|
||||
tech.md system architecture
|
||||
design.md interaction behavior
|
||||
dev-loop.md how software Work is delivered
|
||||
slices.md how Zopu product is incrementally built
|
||||
dev-plan.md engineering order and release gates
|
||||
evaluation.md how quality and improvements are measured
|
||||
glossary.md exact term definitions
|
||||
agent-context compact instructions for execution agents
|
||||
```
|
||||
|
||||
## First build target
|
||||
|
||||
```text
|
||||
message
|
||||
→ Signal
|
||||
→ approved Work Definition
|
||||
→ approved Design Packet
|
||||
→ one isolated implementation slice
|
||||
→ independent verification
|
||||
→ exact verified PR
|
||||
→ human question/resume
|
||||
```
|
||||
|
||||
Start at `dev-plan.md` backlog item 01. Do not begin dynamic Kit Builder or fleets before Slices 1–7 work reliably.
|
||||
665
docs/TECH.md
665
docs/TECH.md
@@ -1,665 +0,0 @@
|
||||
# Zopu Work OS — Technical Architecture
|
||||
|
||||
> **Related:** `agent-context.md` defines agent rules; `dev-loop.md` defines orchestration; `glossary.md` defines terms.
|
||||
|
||||
> **Status:** Working technical source of truth
|
||||
> **Audience:** engineers and implementation agents
|
||||
> **Read after:** `product.md`
|
||||
> **Principle:** durable intent/state is separate from disposable execution.
|
||||
|
||||
## 1. System topology
|
||||
|
||||
```text
|
||||
Web / desktop / mobile
|
||||
│ Convex queries, mutations, storage
|
||||
▼
|
||||
Convex application backend
|
||||
├── authentication and tenancy
|
||||
├── normalized product data
|
||||
├── conversation turn queue
|
||||
└── reactive client projections
|
||||
│ service-authenticated dispatch
|
||||
▼
|
||||
FLUE orchestration service
|
||||
├── model calls
|
||||
├── typed tools
|
||||
└── canonical Flue persistence in Convex
|
||||
│ later execution commands
|
||||
▼
|
||||
Rivet Engine + AgentOS (post-Slice 1)
|
||||
└── sandboxes, harnesses, and durable execution
|
||||
```
|
||||
|
||||
Clients MUST communicate only with Convex. FLUE and future Rivet/AgentOS
|
||||
services are private workers: Convex admits durable commands, invokes the
|
||||
worker, and stores the product-facing result before clients observe it.
|
||||
|
||||
### Ownership
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| Convex | authentication, normalized product records, command admission, reactive reads |
|
||||
| FLUE | private programmable orchestration and domain-specific agents |
|
||||
| Rivet actors (later) | serialized execution ownership, recovery, timers, leases |
|
||||
| Harness | bounded coding/tool loop |
|
||||
| Sandbox/runtime | filesystem, processes, network, isolation |
|
||||
| Git | source revision history |
|
||||
| Artifact store | durable outputs/evidence |
|
||||
| External systems | collaboration, delivery, monitoring |
|
||||
|
||||
No client, harness, sandbox, or FLUE process owns product state.
|
||||
|
||||
### Slice 1 relational core
|
||||
|
||||
```text
|
||||
organizations ──< organizationMembers
|
||||
organizations ──< projects ──< projectContextDocuments
|
||||
organizations ──1 conversations ──< conversationTurns
|
||||
conversationTurns ──< conversationMessages ──< conversationAttachments
|
||||
projects ──< signals ──< signalConstraints
|
||||
signals ──< signalSources >── conversationMessages
|
||||
projects ──< works
|
||||
signals ──< signalWorkAttachments >── works
|
||||
works ──< workEvents
|
||||
```
|
||||
|
||||
Convex mutations provide atomic transactions and optimistic serializability.
|
||||
Foreign-key integrity and uniqueness are enforced in the owning mutations;
|
||||
composite indexes back every identity and relation lookup. Flue's adapter
|
||||
tables remain isolated infrastructure persistence and are not product-domain
|
||||
relations.
|
||||
|
||||
## 2. Domain boundaries
|
||||
|
||||
Recommended packages:
|
||||
|
||||
```text
|
||||
packages/
|
||||
├── signals/
|
||||
├── work/
|
||||
├── design/
|
||||
├── planning/
|
||||
├── kits/
|
||||
├── resolver/
|
||||
├── verification/
|
||||
├── artifacts/
|
||||
├── results/
|
||||
├── knowledge/
|
||||
├── runtimes/
|
||||
├── harnesses/
|
||||
└── integrations/
|
||||
```
|
||||
|
||||
Each domain SHOULD separate:
|
||||
|
||||
```text
|
||||
domain/ pure state, schemas, invariants
|
||||
application/ use cases and orchestration
|
||||
ports/ Effect services
|
||||
adapters/ infrastructure implementations
|
||||
```
|
||||
|
||||
Avoid package proliferation in v0; logical boundaries may begin as folders.
|
||||
|
||||
## 3. Core records
|
||||
|
||||
Minimal durable model:
|
||||
|
||||
```ts
|
||||
type Id = string
|
||||
|
||||
interface Message {
|
||||
id: Id
|
||||
projectId: Id
|
||||
content: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface Signal {
|
||||
id: Id
|
||||
projectId: Id
|
||||
sourceType: string
|
||||
sourceId: string
|
||||
sourcePayloadRef?: string
|
||||
summary: string
|
||||
fingerprint: string
|
||||
status: "candidate" | "accepted" | "dismissed"
|
||||
}
|
||||
|
||||
interface Work {
|
||||
id: Id
|
||||
projectId: Id
|
||||
title: string
|
||||
objective: string
|
||||
risk: "low" | "medium" | "high"
|
||||
status: WorkStatus
|
||||
definitionVersion?: number
|
||||
designVersion?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: Id
|
||||
workId: Id
|
||||
sliceId?: Id
|
||||
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe"
|
||||
objective: string
|
||||
dependsOn: readonly Id[]
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId: Id
|
||||
kitVersion: string
|
||||
status: RunStatus
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
id: Id
|
||||
runId: Id
|
||||
number: number
|
||||
harness: string
|
||||
runtime: string
|
||||
sourceRevision: string
|
||||
status: AttemptStatus
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
}
|
||||
|
||||
interface Artifact {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId?: Id
|
||||
runId?: Id
|
||||
attemptId?: Id
|
||||
type: string
|
||||
uri?: string
|
||||
contentHash?: string
|
||||
sourceRevision?: string
|
||||
environmentId?: string
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId?: Id
|
||||
attemptId?: Id
|
||||
prompt: string
|
||||
recommendation?: string
|
||||
alternatives: readonly string[]
|
||||
status: "open" | "answered" | "withdrawn"
|
||||
answer?: string
|
||||
}
|
||||
```
|
||||
|
||||
All external/model payloads MUST be decoded with schemas at boundaries.
|
||||
|
||||
## 4. Work state machine
|
||||
|
||||
```text
|
||||
Proposed
|
||||
→ Defining
|
||||
→ AwaitingDefinitionApproval
|
||||
→ Designing
|
||||
→ AwaitingDesignApproval
|
||||
→ Ready
|
||||
→ ExecutingSlice
|
||||
→ VerifyingSlice
|
||||
→ AwaitingSliceReview
|
||||
→ IntegratedVerification
|
||||
→ Review
|
||||
→ Releasing
|
||||
→ Observing
|
||||
→ Completed
|
||||
```
|
||||
|
||||
Side/terminal states:
|
||||
|
||||
```text
|
||||
NeedsInput | Blocked | Replanning | Failed | Cancelled
|
||||
```
|
||||
|
||||
Transitions require explicit commands plus evidence. State MUST NOT be inferred from the latest text message.
|
||||
|
||||
## 5. Actor model
|
||||
|
||||
Start small.
|
||||
|
||||
### ProjectActor
|
||||
|
||||
Owns:
|
||||
|
||||
- project configuration;
|
||||
- repository/runtime policies;
|
||||
- active Work index;
|
||||
- integration endpoints;
|
||||
- project-level Signal routing.
|
||||
|
||||
### WorkActor
|
||||
|
||||
Initial central aggregate:
|
||||
|
||||
- Work Definition and versions;
|
||||
- Design Packet and versions;
|
||||
- slice/step graph;
|
||||
- Resolver state;
|
||||
- approvals/questions;
|
||||
- artifact references;
|
||||
- result.
|
||||
|
||||
It serializes lifecycle transitions and commands.
|
||||
|
||||
### AttemptActor
|
||||
|
||||
Owns one execution attempt:
|
||||
|
||||
- runtime lease/sandbox ID;
|
||||
- harness session;
|
||||
- scoped credentials;
|
||||
- event stream/checkpoints;
|
||||
- cancellation;
|
||||
- attempt timeout;
|
||||
- normalized outcome.
|
||||
|
||||
### VerificationActor
|
||||
|
||||
Split from WorkActor after v0 verification is stable. Owns plan, checks, evidence, verdict.
|
||||
|
||||
### IntegrationActor
|
||||
|
||||
Added when multiple slices/branches exist. Owns branch composition, conflicts, integrated revision, integrated checks.
|
||||
|
||||
### ResultActor
|
||||
|
||||
Added after delivery observation exists. Owns rollout observation, original success signals, final outcome, learning proposals.
|
||||
|
||||
Do not create an actor per document or tool call. Create actors for durable identity, serialized ownership, independent failure/recovery, or timers.
|
||||
|
||||
## 6. Commands, events, and idempotency
|
||||
|
||||
Use command/event vocabulary rather than mutable agent prose.
|
||||
|
||||
Example commands:
|
||||
|
||||
```text
|
||||
ProcessMessage
|
||||
AcceptSignal
|
||||
CreateWork
|
||||
ApproveDefinition
|
||||
ApproveDesign
|
||||
StartNextSlice
|
||||
RecordHarnessEvent
|
||||
CompleteAttempt
|
||||
StartVerification
|
||||
RecordVerificationCheck
|
||||
CreateRepairAttempt
|
||||
PublishPullRequest
|
||||
RecordHumanDecision
|
||||
CompleteWork
|
||||
```
|
||||
|
||||
Example events:
|
||||
|
||||
```text
|
||||
SignalCreated
|
||||
SignalAttached
|
||||
WorkProposed
|
||||
DefinitionGenerated
|
||||
DefinitionApproved
|
||||
DesignGenerated
|
||||
DesignApproved
|
||||
SliceStarted
|
||||
AttemptStarted
|
||||
AttemptCompleted
|
||||
VerificationPassed
|
||||
VerificationFailed
|
||||
QuestionOpened
|
||||
QuestionAnswered
|
||||
PullRequestCreated
|
||||
WorkCompleted
|
||||
```
|
||||
|
||||
Every side-effecting command MUST include an idempotency key. Suggested key:
|
||||
|
||||
```text
|
||||
<workId>:<commandType>:<logicalVersion>
|
||||
```
|
||||
|
||||
External artifact creation stores provider ID before transition completion to prevent duplicate PRs/deployments.
|
||||
|
||||
## 7. Effect service boundaries
|
||||
|
||||
Keep domain/application code provider-neutral.
|
||||
|
||||
```ts
|
||||
interface HarnessRuntime {
|
||||
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>
|
||||
prompt(id: string, content: string): Effect.Effect<void, HarnessError>
|
||||
events(id: string): Stream.Stream<HarnessEvent, HarnessError>
|
||||
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>
|
||||
abort(id: string): Effect.Effect<void, HarnessError>
|
||||
close(id: string): Effect.Effect<void, HarnessError>
|
||||
}
|
||||
|
||||
interface SandboxRuntime {
|
||||
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>
|
||||
exec(lease: SandboxLease, cmd: Command): Effect.Effect<CommandResult, SandboxError>
|
||||
readFile(lease: SandboxLease, path: string): Effect.Effect<Uint8Array, SandboxError>
|
||||
writeFile(lease: SandboxLease, path: string, body: Uint8Array): Effect.Effect<void, SandboxError>
|
||||
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
||||
resume(id: string): Effect.Effect<SandboxLease, SandboxError>
|
||||
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
||||
}
|
||||
|
||||
interface SourceControl {
|
||||
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>
|
||||
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>
|
||||
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>
|
||||
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>
|
||||
createPullRequest(input: PullRequestInput): Effect.Effect<PullRequestArtifact, GitError>
|
||||
}
|
||||
|
||||
interface VerificationRuntime {
|
||||
execute(plan: VerificationPlan, env: EnvironmentRef):
|
||||
Effect.Effect<VerificationResult, VerificationError>
|
||||
}
|
||||
```
|
||||
|
||||
Also define:
|
||||
|
||||
```text
|
||||
ArtifactStore | SecretBroker | EventJournal | PreviewRuntime | RuntimePolicy
|
||||
```
|
||||
|
||||
Use `Layer` for adapters, `Scope` for leases/processes, `Stream` for events, `Schedule` for bounded retries, and supervised fibers for long-running consumers. Pin Effect version and isolate beta API churn.
|
||||
|
||||
## 8. FLUE responsibilities
|
||||
|
||||
Use FLUE for domain-specific agents/workflows:
|
||||
|
||||
- conversation response and Signal proposal;
|
||||
- Work Definition compiler;
|
||||
- impact analysis;
|
||||
- architecture/program design;
|
||||
- vertical-slice planning;
|
||||
- Resolver decision support;
|
||||
- verification-plan generation;
|
||||
- maintainability review;
|
||||
- result/learning synthesis.
|
||||
|
||||
FLUE output MUST be typed proposals/commands, validated by application policy.
|
||||
|
||||
FLUE MUST NOT directly:
|
||||
|
||||
- mutate arbitrary database tables;
|
||||
- bypass actor lifecycle;
|
||||
- grant itself tools;
|
||||
- merge/deploy outside policy;
|
||||
- mark Work complete without evidence.
|
||||
|
||||
## 9. Harness strategy
|
||||
|
||||
Harness is replaceable:
|
||||
|
||||
```text
|
||||
HarnessRuntime
|
||||
├── OmpHarnessLive
|
||||
├── OpenCodeHarnessLive
|
||||
├── CodexHarnessLive
|
||||
├── PiHarnessLive
|
||||
└── FlueHarnessLive
|
||||
```
|
||||
|
||||
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, approvals, artifacts, and completion. Harness owns one bounded implementation loop.
|
||||
|
||||
## 10. Runtime strategy
|
||||
|
||||
### CubeSandbox
|
||||
|
||||
Best for full Linux execution:
|
||||
|
||||
- native binaries;
|
||||
- Bun/Node/Python;
|
||||
- browsers;
|
||||
- databases/services;
|
||||
- project builds/tests;
|
||||
- pause/resume;
|
||||
- strong isolation.
|
||||
|
||||
Use E2B-compatible SDK behind `SandboxRuntime`. OMP runs as a process inside the Cube microVM.
|
||||
|
||||
### AgentOS
|
||||
|
||||
Best for:
|
||||
|
||||
- lightweight durable agent environments;
|
||||
- ACP-integrated software;
|
||||
- actor-adjacent orchestration;
|
||||
- context/files/networking that fit runtime limits.
|
||||
|
||||
Use an attached full sandbox when native/heavy tooling is needed.
|
||||
|
||||
### Persistent project machine
|
||||
|
||||
Later optimization for long-lived caches, large repositories, and developer-customized environments. Use Git worktrees per active Work. Do not make it the only isolation boundary.
|
||||
|
||||
### Runtime selection
|
||||
|
||||
The Resolver requests capabilities:
|
||||
|
||||
```text
|
||||
writable repo, Bun, browser, PostgreSQL, 8 GB RAM, network policy
|
||||
```
|
||||
|
||||
`RuntimePolicy` chooses provider. Product logic never branches on Cube/AgentOS directly.
|
||||
|
||||
## 11. Repository isolation
|
||||
|
||||
Initial safe model:
|
||||
|
||||
```text
|
||||
one project repository mirror
|
||||
one Git worktree per active Work/slice
|
||||
one mutating attempt per worktree
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- attempts receive scoped worktrees;
|
||||
- parallel mutating attempts use separate branches;
|
||||
- credentials are short-lived and repository-scoped;
|
||||
- host home directories are never mounted;
|
||||
- model credentials are run-scoped;
|
||||
- untrusted code runs in sandbox;
|
||||
- output commits record base and candidate SHA.
|
||||
|
||||
## 12. Planning and design artifacts
|
||||
|
||||
Required for standard work:
|
||||
|
||||
```text
|
||||
WorkDefinition
|
||||
ImpactMap
|
||||
DesignPacket
|
||||
VerticalSlicePlan
|
||||
VerificationPlan
|
||||
```
|
||||
|
||||
Program design SHOULD include:
|
||||
|
||||
- expected file-tree delta;
|
||||
- expected call-flow delta;
|
||||
- key types/signatures;
|
||||
- dependency direction;
|
||||
- invariants;
|
||||
- security/data boundaries;
|
||||
- known deviations.
|
||||
|
||||
The verifier compares candidate code against this design, but metrics are evidence, not absolute truth.
|
||||
|
||||
## 13. Verification architecture
|
||||
|
||||
Verification layers:
|
||||
|
||||
```text
|
||||
Static
|
||||
├── format/lint/typecheck
|
||||
├── dependency policy
|
||||
├── secret scan
|
||||
└── static security
|
||||
|
||||
Behavior
|
||||
├── unit
|
||||
├── integration
|
||||
├── contract
|
||||
└── property tests where useful
|
||||
|
||||
Product
|
||||
├── browser/user flow
|
||||
├── screenshots/video
|
||||
├── accessibility
|
||||
└── visual checks
|
||||
|
||||
Operational
|
||||
├── build/start/health
|
||||
├── migration/rollback
|
||||
├── logs
|
||||
└── resource limits
|
||||
|
||||
Design
|
||||
├── expected vs actual files/interfaces
|
||||
├── dependency graph delta
|
||||
├── design deviations
|
||||
└── maintainability review
|
||||
```
|
||||
|
||||
A `VerificationResult` MUST bind checks to candidate commit and environment.
|
||||
|
||||
Repair loop:
|
||||
|
||||
```text
|
||||
failure evidence → bounded repair attempt → clean verification rerun
|
||||
```
|
||||
|
||||
Tests added by the implementation SHOULD fail on the base revision and pass on the candidate when practical.
|
||||
|
||||
## 14. Delivery and integration
|
||||
|
||||
Publishing order:
|
||||
|
||||
```text
|
||||
slice checks pass
|
||||
→ integrated candidate created
|
||||
→ impacted checks on integrated SHA
|
||||
→ commit/push
|
||||
→ PR
|
||||
→ review package
|
||||
```
|
||||
|
||||
Never create a “verified PR” from a different SHA than the verified candidate.
|
||||
|
||||
Review package:
|
||||
|
||||
- original intent;
|
||||
- approved definition/design;
|
||||
- slice narrative;
|
||||
- meaningful diffs;
|
||||
- screenshots/video;
|
||||
- verification evidence;
|
||||
- deviations/risks;
|
||||
- exact commit/PR.
|
||||
|
||||
Manual merge remains policy in v0.
|
||||
|
||||
## 15. Preview and release
|
||||
|
||||
Preview is an artifact, not an open random port. `PreviewRuntime` may use:
|
||||
|
||||
- sandbox-exposed app;
|
||||
- agentOS Apps for compatible generated HTTP apps;
|
||||
- external staging/deployment.
|
||||
|
||||
Production release requires explicit policy, rollout plan, health signals, and rollback trigger.
|
||||
|
||||
## 16. Security baseline
|
||||
|
||||
- private control-plane endpoints;
|
||||
- authenticated Cube/Rivet APIs;
|
||||
- scoped runtime tokens;
|
||||
- no long-lived model/Git secrets in workspace files;
|
||||
- network egress policy;
|
||||
- artifact access authorization;
|
||||
- immutable audit trail for approvals;
|
||||
- tool allowlist per Kit;
|
||||
- destructive tools denied by default;
|
||||
- merge/deploy human-gated initially;
|
||||
- cleanup of terminated sandboxes and credentials.
|
||||
|
||||
## 17. Observability
|
||||
|
||||
Record product-level events, not only infrastructure logs.
|
||||
|
||||
Required dimensions:
|
||||
|
||||
```text
|
||||
projectId workId sliceId runId attemptId actorId
|
||||
kitVersion harness runtime model baseSha candidateSha
|
||||
```
|
||||
|
||||
Track:
|
||||
|
||||
- state-transition latency;
|
||||
- attempt duration/outcome;
|
||||
- retries/replans;
|
||||
- verification checks;
|
||||
- human wait time;
|
||||
- token/compute cost;
|
||||
- duplicate side effects;
|
||||
- abandoned/stale Work;
|
||||
- post-release failures.
|
||||
|
||||
Raw harness logs are retained as artifacts; UI consumes normalized events.
|
||||
|
||||
## 18. Initial deployment shape
|
||||
|
||||
```text
|
||||
Web + API + Convex
|
||||
│
|
||||
▼
|
||||
Bun/Effect daemon
|
||||
│
|
||||
▼
|
||||
Rivet cluster/actors
|
||||
│
|
||||
├── FLUE agents/workflows
|
||||
└── SandboxRuntime
|
||||
└── CubeSandbox on dedicated/VDS host
|
||||
└── OMP + repo + tests
|
||||
```
|
||||
|
||||
Keep Cube control APIs private; expose only authorized preview paths. Rivet and execution daemon may share the VPS initially but remain separate deployable processes.
|
||||
|
||||
## 19. Technical acceptance for v0
|
||||
|
||||
The architecture is proven when one real repository supports:
|
||||
|
||||
```text
|
||||
message → Signal → approved Work → approved Design Packet
|
||||
→ one slice → isolated harness run → independent verification
|
||||
→ verified commit → real PR → human response/resume
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
- durable recovery;
|
||||
- no duplicate Work/PR;
|
||||
- bounded retries;
|
||||
- exact evidence;
|
||||
- cancellation;
|
||||
- explicit terminal states.
|
||||
@@ -1,328 +0,0 @@
|
||||
# Zopu Work OS — Agent Context
|
||||
|
||||
> **Purpose:** compact bootstrap context for Codex/OMP/OpenCode/FLUE workers contributing to Zopu.
|
||||
> **Usage:** read this first, then load only task-relevant documents and repository files.
|
||||
|
||||
## 1. Mission
|
||||
|
||||
Build Zopu: a Work OS that turns conversation and external Signals into durable, verified outcomes.
|
||||
|
||||
```text
|
||||
Signal → Work → Definition → Design → Vertical Slices
|
||||
→ Resolver → Attempts → Verification → Delivery → Result → Learning
|
||||
```
|
||||
|
||||
The product is not chat threads, an issue tracker, a harness UI, or an autonomous PR factory.
|
||||
|
||||
## 2. Source-of-truth order
|
||||
|
||||
```text
|
||||
1. current accepted Work/Question/Decision
|
||||
2. approved Work Definition
|
||||
3. approved Design Packet + current Slice
|
||||
4. repository tests/types/code
|
||||
5. product.md
|
||||
6. tech.md
|
||||
7. design.md
|
||||
8. dev-loop.md
|
||||
9. slices.md
|
||||
10. dev-plan.md
|
||||
11. glossary.md
|
||||
12. evaluation.md
|
||||
```
|
||||
|
||||
When sources conflict, report the conflict. Do not silently choose a convenient interpretation.
|
||||
|
||||
## 3. Current product target
|
||||
|
||||
Initial user:
|
||||
|
||||
```text
|
||||
technical founder / small engineering team
|
||||
one project + one Git repository
|
||||
web chat + Work cards
|
||||
manual merge
|
||||
```
|
||||
|
||||
Initial complete loop:
|
||||
|
||||
```text
|
||||
message
|
||||
→ Signal
|
||||
→ approved Work
|
||||
→ approved Design Packet
|
||||
→ one bounded coding slice
|
||||
→ independent verification
|
||||
→ exact verified commit
|
||||
→ real PR
|
||||
→ human intervention/resume
|
||||
```
|
||||
|
||||
## 4. Non-negotiable invariants
|
||||
|
||||
1. Work is a durable outcome, not a chat/session/issue/PR.
|
||||
2. Exact Signal provenance is preserved.
|
||||
3. State transitions are explicit commands/events.
|
||||
4. FLUE/model output is validated proposal data, not authority.
|
||||
5. Rivet actors own serialized lifecycle/recovery.
|
||||
6. Harnesses and runtimes are replaceable adapters.
|
||||
7. One mutating Attempt owns one isolated worktree.
|
||||
8. Every Attempt is bounded and terminally classified.
|
||||
9. Builder output remains candidate output until independent verification.
|
||||
10. Evidence binds to exact revision and environment.
|
||||
11. Published PR head equals verified integrated SHA.
|
||||
12. Human questions/approvals are durable objects.
|
||||
13. Retry/restart must not duplicate Work, commits, PRs, or deployments.
|
||||
14. Merge/deploy remain human-gated initially.
|
||||
15. Completion requires outcome evidence, not an agent’s “done.”
|
||||
|
||||
## 5. System ownership
|
||||
|
||||
```text
|
||||
Work OS durable intent/state/evidence/policy
|
||||
Rivet actors identity, serialization, timers, recovery
|
||||
FLUE domain agents/workflows and typed proposals
|
||||
Harness bounded coding/tool loop
|
||||
Sandbox filesystem/process/network isolation
|
||||
Git source history
|
||||
ArtifactStore durable output/evidence
|
||||
UI/Buzz interaction surfaces, not workflow truth
|
||||
```
|
||||
|
||||
## 6. Initial actor shape
|
||||
|
||||
```text
|
||||
ProjectActor
|
||||
├── project config + active Work index
|
||||
|
||||
WorkActor
|
||||
├── definition/design/slices
|
||||
├── resolver state
|
||||
├── questions/approvals
|
||||
├── artifacts/result
|
||||
|
||||
AttemptActor
|
||||
├── runtime lease
|
||||
├── harness session
|
||||
├── normalized events
|
||||
├── timeout/cancellation/outcome
|
||||
```
|
||||
|
||||
Add Verification/Integration/Result actors only when their lifecycles justify separation.
|
||||
|
||||
## 7. Initial adapters
|
||||
|
||||
Preferred first implementation:
|
||||
|
||||
```text
|
||||
FLUE definition/design/resolver support
|
||||
Rivet actors
|
||||
CubeSandbox full Linux runtime
|
||||
OMP first coding harness, replaceable
|
||||
Git forge branch/commit/PR
|
||||
Convex durable application data/projections where used
|
||||
Effect application ports/layers/errors/scopes/streams
|
||||
```
|
||||
|
||||
Do not import provider-specific types into domain modules.
|
||||
|
||||
## 8. Working protocol for every code task
|
||||
|
||||
### Before editing
|
||||
|
||||
1. Identify current vertical slice and acceptance criteria.
|
||||
2. Read relevant docs and repository rules.
|
||||
3. Inspect current architecture, tests, and existing abstractions.
|
||||
4. State impacted modules and risks.
|
||||
5. Produce/confirm a small implementation plan.
|
||||
6. Ask a Question only when ambiguity changes behavior, security, data, or architecture.
|
||||
|
||||
### During implementation
|
||||
|
||||
1. Stay within the current slice.
|
||||
2. Preserve dependency direction.
|
||||
3. Prefer existing patterns over parallel frameworks.
|
||||
4. Add typed boundaries and tagged errors.
|
||||
5. Make external effects idempotent.
|
||||
6. Add cancellation/timeouts for long-running operations.
|
||||
7. Record exact revisions/provider identifiers.
|
||||
8. Add tests with the implementation.
|
||||
9. Do not weaken unrelated tests or hide failures.
|
||||
10. Explain intentional Design Packet deviations.
|
||||
|
||||
### Before declaring candidate ready
|
||||
|
||||
Run task-relevant checks:
|
||||
|
||||
```text
|
||||
format/lint/typecheck
|
||||
focused unit/integration tests
|
||||
behavioral/live check
|
||||
security/secret check where relevant
|
||||
design-conformance review
|
||||
```
|
||||
|
||||
Report:
|
||||
|
||||
```text
|
||||
files changed
|
||||
behavior implemented
|
||||
checks run + results
|
||||
known risks/deviations
|
||||
artifacts/revision
|
||||
remaining blockers
|
||||
```
|
||||
|
||||
Never claim verified unless the independent verification path ran.
|
||||
|
||||
## 9. Code organization rule
|
||||
|
||||
Use logical separation:
|
||||
|
||||
```text
|
||||
domain/ pure types/state/invariants
|
||||
application/ use cases/commands
|
||||
ports/ Effect services
|
||||
adapters/ FLUE/Rivet/Cube/OMP/Git implementations
|
||||
```
|
||||
|
||||
Keep the initial package count practical. A folder boundary is enough until independent reuse/lifecycle exists.
|
||||
|
||||
## 10. State modeling rule
|
||||
|
||||
Prefer explicit state machines over booleans.
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
isRunning: boolean
|
||||
isDone: boolean
|
||||
hasError: boolean
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
type AttemptStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "retryable_failure"
|
||||
| "needs_input"
|
||||
| "blocked"
|
||||
| "verification_failed"
|
||||
| "budget_exhausted"
|
||||
| "cancelled"
|
||||
| "permanent_failure"
|
||||
```
|
||||
|
||||
Transitions must validate current version/state.
|
||||
|
||||
## 11. Model/agent output rule
|
||||
|
||||
Agents return schemas such as:
|
||||
|
||||
```text
|
||||
SignalProposal
|
||||
WorkDefinitionProposal
|
||||
DesignPacketProposal
|
||||
ResolverDecisionProposal
|
||||
VerificationAssessment
|
||||
LearningProposal
|
||||
```
|
||||
|
||||
Application code decodes, authorizes, and executes commands. Agents do not directly mutate arbitrary state.
|
||||
|
||||
## 12. Quality rule
|
||||
|
||||
For every behavior, identify:
|
||||
|
||||
```text
|
||||
acceptance criterion
|
||||
implementation evidence
|
||||
independent check
|
||||
exact candidate revision
|
||||
review requirement
|
||||
```
|
||||
|
||||
Tests prove immediate behavior. Design review protects maintainability and future change cost. Both matter.
|
||||
|
||||
## 13. UI rule
|
||||
|
||||
Expose:
|
||||
|
||||
```text
|
||||
outcome
|
||||
phase
|
||||
latest meaningful update
|
||||
next action/blocker
|
||||
slice progress
|
||||
evidence
|
||||
delivery/result
|
||||
```
|
||||
|
||||
Hide low-level harness noise by default. Preserve raw logs in diagnostics.
|
||||
|
||||
## 14. Scope control
|
||||
|
||||
Do not implement unless required by the current slice:
|
||||
|
||||
- dynamic Kit Builder;
|
||||
- multiple harness providers;
|
||||
- large fleets;
|
||||
- autonomous merge/deploy;
|
||||
- universal workflows;
|
||||
- automatic canonical-memory mutation;
|
||||
- sophisticated multi-tenancy.
|
||||
|
||||
Design replaceable boundaries, then ship one path.
|
||||
|
||||
## 15. Task completion template
|
||||
|
||||
```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 |
|
||||
|
||||
## 17. Stop and escalate when
|
||||
|
||||
- accepted definition/design is missing or contradictory;
|
||||
- required permission is unavailable;
|
||||
- worktree/revision identity is uncertain;
|
||||
- a destructive action is outside policy;
|
||||
- verification cannot bind to exact candidate;
|
||||
- implementation requires expanding scope materially;
|
||||
- a retry would repeat a non-transient failure;
|
||||
- repository state suggests another active mutating owner.
|
||||
|
||||
Return a precise Question or blocker, not a guess.
|
||||
35
docs/agents/domain.md
Normal file
35
docs/agents/domain.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
43
docs/agents/issue-tracker.md
Normal file
43
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Issue tracker: Gitea + local markdown
|
||||
|
||||
This repo uses a hybrid workflow: issues are drafted and triaged locally as markdown files under `.scratch/`, then published as polished issues to Gitea (`git.openputer.com:2222/puter/zopu-code`) when they are ready for the public tracker.
|
||||
|
||||
## Local markdown conventions
|
||||
|
||||
- One feature per directory: `.scratch/<feature-slug>/`
|
||||
- The spec/PRD is `.scratch/<feature-slug>/spec.md`
|
||||
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
|
||||
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
|
||||
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
|
||||
|
||||
## Gitea conventions
|
||||
|
||||
Use the Gitea web UI or API at `https://git.openputer.com/puter/zopu-code/issues`.
|
||||
|
||||
- Create a polished issue from a local draft: copy the title and body from the final local file, then create the issue in Gitea.
|
||||
- Read an issue: open the issue in the Gitea UI or fetch it via the API.
|
||||
- List issues: use the Gitea UI or API with label/state filters.
|
||||
- Comment on an issue: add a comment through the Gitea UI or API.
|
||||
- Apply / remove labels: edit labels through the Gitea UI or API.
|
||||
- Close an issue: close through the Gitea UI or API.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
1. If the output is a draft, spec, or work-in-progress, write it under `.scratch/<feature-slug>/`.
|
||||
2. If the output is a final, polished issue, create it in Gitea and, if helpful, archive the local draft with a link to the Gitea issue number.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
- For local drafts: read the file at the referenced path.
|
||||
- For Gitea issues: open or fetch the issue by its Gitea number.
|
||||
|
||||
## Wayfinding operations
|
||||
|
||||
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
|
||||
|
||||
- **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
|
||||
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
|
||||
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
|
||||
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
|
||||
- **Claim**: set `Status: claimed` and save before any work.
|
||||
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
|
||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
79
docs/auth-proxy.md
Normal file
79
docs/auth-proxy.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
|
||||
## Required production route
|
||||
|
||||
At the public application domain, route:
|
||||
|
||||
| Prefix | Target |
|
||||
| ------------- | --------------------- |
|
||||
| `/api/auth/*` | Convex HTTP site |
|
||||
| `/*` | React Router frontend |
|
||||
|
||||
### Caddy
|
||||
|
||||
```caddy
|
||||
zopu.example.com {
|
||||
handle /api/auth/* {
|
||||
reverse_proxy https://joyous-cat-297.convex.site {
|
||||
header_up Host joyous-cat-297.convex.site
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dokploy / Traefik
|
||||
|
||||
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)
|
||||
- Preserves the original method and body
|
||||
- Forwards `X-Forwarded-Host` and `X-Forwarded-Proto`
|
||||
- Passes the full path unchanged (e.g. `/api/auth/convex/token`)
|
||||
- Does not cache auth responses
|
||||
- Allows OAuth callback routes under the same prefix
|
||||
|
||||
## Convex environment
|
||||
|
||||
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex site URL:
|
||||
|
||||
```bash
|
||||
# Production
|
||||
npx convex env set SITE_URL 'https://zopu.example.com'
|
||||
|
||||
# Local
|
||||
npx convex env set SITE_URL 'http://100.101.157.28:5173'
|
||||
|
||||
# Staging
|
||||
npx convex env set SITE_URL 'https://zopu-staging.vercel.app'
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
3. **No CORS complexity.** The browser talks only to its own origin.
|
||||
4. **The reverse proxy handles the cross-host hop server-side.**
|
||||
|
||||
## Related
|
||||
|
||||
- [Better Auth: Trusted Origins](https://github.com/better-auth/better-auth/blob/main/docs/content/docs/reference/security.mdx)
|
||||
- `apps/web/vite.config.ts` — Vite dev proxy (`/api/auth` → Convex site)
|
||||
- `packages/auth/src/web/auth-client.ts` — `window.location.origin`
|
||||
- `apps/web/src/lib/auth.server.ts` — SSR token loader via request origin
|
||||
@@ -1,233 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Environments
|
||||
|
||||
| | Local Mac Dev | Cheaptricks Staging |
|
||||
| --- | --- | --- |
|
||||
| SSH alias | (local) | `cheaptricks` |
|
||||
| Repo path | `/Users/puter/Workspace/zopu/code` | `/workspace/code` |
|
||||
| Tailscale IPv4 | `100.101.157.28` | `100.122.185.111` |
|
||||
| Web URL | `http://100.101.157.28:5173` | `https://zopu.cheaptricks.puter.wtf` |
|
||||
| Flue URL (internal) | `http://100.101.157.28:3585` | `http://127.0.0.1:3585` |
|
||||
| Flue URL (browser-facing) | `http://100.101.157.28:3585` | `https://zopu.cheaptricks.puter.wtf/api` |
|
||||
| Caddy | none (direct Tailscale) | `zopu.cheaptricks.puter.wtf` HTTPS termination |
|
||||
| Bun | installed directly | symlink at `/workspace/.bun` |
|
||||
| Process manager | manual `bun run dev:tailscale:*` | `nohup` into `/tmp/zopu-*.log` |
|
||||
|
||||
## 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`
|
||||
|
||||
Convex env vars are deployment-scoped, not per-machine. Authenticate from any
|
||||
machine with `npx convex dev` inside `packages/backend`.
|
||||
|
||||
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.
|
||||
|
||||
When switching between environments:
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
|
||||
# For local Mac testing:
|
||||
npx convex env set SITE_URL 'http://100.101.157.28:5173'
|
||||
|
||||
# For Cheaptricks staging:
|
||||
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
|
||||
```
|
||||
|
||||
## Local Mac Dev `.env`
|
||||
|
||||
```env
|
||||
CONVEX_DEPLOYMENT=dev:befitting-dalmatian-161
|
||||
CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
|
||||
CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
|
||||
SITE_URL=http://100.101.157.28:5173
|
||||
NATIVE_APP_URL=code://
|
||||
|
||||
VITE_CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
|
||||
VITE_CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
|
||||
VITE_FLUE_URL=http://100.101.157.28:3585
|
||||
|
||||
DAEMON_ID=local-macbook
|
||||
DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
|
||||
FLUE_DB_TOKEN=<from secrets manager>
|
||||
|
||||
AGENT_MODEL_PROVIDER=xiaomi
|
||||
AGENT_MODEL_NAME=mimo-v2.5
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_BASE_URL=<cheaptricks gateway base url>
|
||||
AGENT_MODEL_API_KEY=<cheaptricks api key>
|
||||
AGENT_MODEL_CONTEXT_WINDOW=1048576
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=<gitea personal access token>
|
||||
```
|
||||
|
||||
### Start local dev
|
||||
|
||||
```bash
|
||||
bun run dev:tailscale:agents -- --port 3585 &
|
||||
bun run dev:tailscale:web &
|
||||
```
|
||||
|
||||
Both bind `0.0.0.0` so they are reachable over Tailscale from a phone.
|
||||
|
||||
Before testing locally, flip the Convex `SITE_URL`:
|
||||
|
||||
```bash
|
||||
cd packages/backend && npx convex env set SITE_URL 'http://100.101.157.28:5173'
|
||||
```
|
||||
|
||||
## Cheaptricks Staging `.env`
|
||||
|
||||
Located at `/workspace/code/.env` on the `cheaptricks` host.
|
||||
|
||||
```env
|
||||
CONVEX_DEPLOYMENT=dev:befitting-dalmatian-161
|
||||
CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
|
||||
CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
|
||||
SITE_URL=https://zopu.cheaptricks.puter.wtf
|
||||
NATIVE_APP_URL=code://
|
||||
|
||||
VITE_CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
|
||||
VITE_CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
|
||||
VITE_FLUE_URL=https://zopu.cheaptricks.puter.wtf/api
|
||||
|
||||
VITE_ZOPU_SERVER_URL=https://zopu.cheaptricks.puter.wtf/api
|
||||
PORT=3590
|
||||
|
||||
DAEMON_ID=local-macbook
|
||||
DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
|
||||
FLUE_DB_TOKEN=<from secrets manager>
|
||||
|
||||
AGENT_MODEL_PROVIDER=xiaomi
|
||||
AGENT_MODEL_NAME=mimo-v2.5
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_BASE_URL=https://ai.cheaptricks.puter.wtf/v1
|
||||
AGENT_MODEL_API_KEY=<cheaptricks api key>
|
||||
AGENT_MODEL_CONTEXT_WINDOW=1048576
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=<gitea personal access token>
|
||||
```
|
||||
|
||||
### Caddy config
|
||||
|
||||
File: `/etc/caddy/Caddyfile` on `cheaptricks`
|
||||
|
||||
```
|
||||
zopu.cheaptricks.puter.wtf {
|
||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
||||
encode zstd gzip
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy 127.0.0.1:3585
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:5173
|
||||
}
|
||||
```
|
||||
|
||||
`/api/*` routes to the Flue agents process (port 3585).
|
||||
Everything else routes to the web dev server (port 5173).
|
||||
|
||||
Reload after changes:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
### Start staging dev
|
||||
|
||||
```bash
|
||||
ssh cheaptricks
|
||||
export PATH=$PATH:/workspace/.bun/bin
|
||||
cd /workspace/code
|
||||
|
||||
# Pull latest
|
||||
git pull origin feat/web-integrarion
|
||||
bun install
|
||||
|
||||
# Start both processes with nohup so they survive SSH disconnect
|
||||
nohup bun run dev:tailscale:agents -- --port 3585 > /tmp/zopu-agents.log 2>&1 &
|
||||
nohup bun run dev:tailscale:web > /tmp/zopu-web.log 2>&1 &
|
||||
```
|
||||
|
||||
Before testing on staging, flip the Convex `SITE_URL`:
|
||||
|
||||
```bash
|
||||
cd packages/backend && npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
|
||||
```
|
||||
|
||||
### Verify staging
|
||||
|
||||
```bash
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' https://zopu.cheaptricks.puter.wtf/
|
||||
# expect: 200
|
||||
|
||||
curl -sS -D - -o /dev/null \
|
||||
'https://befitting-dalmatian-161.convex.site/api/auth/get-session' \
|
||||
-H 'Origin: https://zopu.cheaptricks.puter.wtf' | grep access-control-allow-origin
|
||||
# expect: access-control-allow-origin: https://zopu.cheaptricks.puter.wtf
|
||||
```
|
||||
|
||||
## Model configuration
|
||||
|
||||
Both environments use the same model via the Cheaptricks AI gateway:
|
||||
|
||||
- Provider identity: `xiaomi` (Flue catalog maps this to MiMo multimodal metadata)
|
||||
- Model: `mimo-v2.5`
|
||||
- API protocol: `openai-completions`
|
||||
- Context window: `1048576`
|
||||
- Max output tokens: `131072`
|
||||
- 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.** 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`.
|
||||
|
||||
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.
|
||||
|
||||
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside
|
||||
`packages/backend` on each new machine to authenticate the CLI.
|
||||
@@ -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 |
|
||||
|
||||
445
docs/dev-plan.md
445
docs/dev-plan.md
@@ -1,445 +0,0 @@
|
||||
# Zopu Work OS — Development Plan
|
||||
|
||||
> **Related:** `slices.md` defines product increments; `dev-loop.md` defines the normative delivery loop; `evaluation.md` defines promotion gates.
|
||||
|
||||
> **Purpose:** concrete implementation order for the repository.
|
||||
> **Strategy:** one vertical product loop, contracts first, provider adapters second, generalization last.
|
||||
|
||||
## 1. Target demonstration
|
||||
|
||||
Use one real repository and one deterministic Work:
|
||||
|
||||
> Add `GET /health` returning `{ status: "ok", commit: "<sha>" }`, with automated and live HTTP verification, then create a verified PR.
|
||||
|
||||
The first release is successful only when the full path works from chat to exact PR evidence.
|
||||
|
||||
## 2. Engineering rules
|
||||
|
||||
1. Land domain contracts before parallel adapter/UI work.
|
||||
2. Keep FLUE, Rivet, Cube, AgentOS, and harness types behind ports.
|
||||
3. One side effect has one idempotency key.
|
||||
4. One mutating attempt owns one worktree.
|
||||
5. Every state transition has a command, event, actor, timestamp, and evidence reference.
|
||||
6. Every async process has timeout, cancellation, retry policy, and terminal classification.
|
||||
7. Builder output is candidate output until independent verification passes.
|
||||
8. Do not generalize until two real use cases require it.
|
||||
9. Do not add an agent role without an evaluation showing quality gain.
|
||||
10. Keep manual merge/deploy gates initially.
|
||||
|
||||
## 3. Repository workstreams
|
||||
|
||||
Recommended logical ownership:
|
||||
|
||||
```text
|
||||
A. Domain/actors
|
||||
B. FLUE definition/design agents
|
||||
C. Resolver/runtime/harness
|
||||
D. Verification/Git
|
||||
E. Web experience
|
||||
F. Deployment/operations
|
||||
```
|
||||
|
||||
Only parallelize after shared schemas and event contracts are merged.
|
||||
|
||||
## 4. Phase 0 — Baseline and contracts
|
||||
|
||||
### Deliverables
|
||||
|
||||
- repository architecture inventory;
|
||||
- one canonical glossary;
|
||||
- schemas for Message, Signal, Work, WorkDefinition, DesignPacket, VerticalSlice, Run, Attempt, Artifact, Question;
|
||||
- Work/Attempt state machines;
|
||||
- command/event list;
|
||||
- port interfaces;
|
||||
- static `CodingKitV0`;
|
||||
- deterministic fixture project/task.
|
||||
|
||||
### Tests
|
||||
|
||||
- schema round trips;
|
||||
- state transition property/table tests;
|
||||
- idempotency tests;
|
||||
- fake adapters;
|
||||
- actor restart/replay tests.
|
||||
|
||||
### Exit gate
|
||||
|
||||
A fake end-to-end test can create Work, approve definition/design, run fake slices, verify, and produce a fake PR artifact.
|
||||
|
||||
## 5. Milestone A — Work exists (Slices 1–2)
|
||||
|
||||
### Issue order
|
||||
|
||||
1. Persist project messages.
|
||||
2. Implement `ProcessMessage`.
|
||||
3. FLUE structured Signal/Work proposal.
|
||||
4. Signal fingerprinting and attach/create rules.
|
||||
5. WorkActor/projection.
|
||||
6. Reactive Work card.
|
||||
7. Work Definition schema/versioning.
|
||||
8. Definition compiler agent.
|
||||
9. definition edit/approval/questions UI.
|
||||
10. approval invalidation tests.
|
||||
|
||||
### Release gate
|
||||
|
||||
```text
|
||||
message → exact Signal → proposed card → approved Work Definition
|
||||
```
|
||||
|
||||
No runtime integration.
|
||||
|
||||
## 6. Milestone B — Work is executable (Slices 3–5)
|
||||
|
||||
### Issue order
|
||||
|
||||
1. ImpactMap/DesignPacket schemas.
|
||||
2. design compiler and slice planner.
|
||||
3. design review/version UI.
|
||||
4. Resolver decision function as pure domain logic.
|
||||
5. WorkActor resolver commands/events.
|
||||
6. FakeHarness/FakeSandbox adapters.
|
||||
7. AttemptActor lifecycle.
|
||||
8. CubeSandbox adapter.
|
||||
9. selected harness adapter (OMP first unless spike rejects it).
|
||||
10. Git worktree preparation.
|
||||
11. normalized activity stream.
|
||||
12. cancellation/timeout/cleanup.
|
||||
|
||||
### Contract freeze before adapter work
|
||||
|
||||
```text
|
||||
HarnessEvent
|
||||
SandboxLease
|
||||
AttemptInput/Outcome
|
||||
Artifact metadata
|
||||
```
|
||||
|
||||
### Release gate
|
||||
|
||||
```text
|
||||
approved design → one real slice → isolated repository changes
|
||||
```
|
||||
|
||||
No PR claim yet.
|
||||
|
||||
## 7. Milestone C — Work is trustworthy (Slices 6–7)
|
||||
|
||||
### Issue order
|
||||
|
||||
1. VerificationPlan schema.
|
||||
2. command-check runner.
|
||||
3. HTTP-check runner.
|
||||
4. revision/environment binding.
|
||||
5. verifier role/process.
|
||||
6. repair-attempt loop.
|
||||
7. design-conformance evidence.
|
||||
8. Git commit/push adapter.
|
||||
9. PR creation adapter with idempotency.
|
||||
10. review-package generator/UI.
|
||||
11. exact-SHA invariant tests.
|
||||
|
||||
### Required quality fixture
|
||||
|
||||
For the health task, prove:
|
||||
|
||||
```text
|
||||
new test fails on base
|
||||
candidate passes focused tests
|
||||
service starts
|
||||
GET /health returns expected body
|
||||
candidate SHA equals PR head SHA
|
||||
```
|
||||
|
||||
### Release gate
|
||||
|
||||
A human can review and merge a real verified PR without reading raw agent logs.
|
||||
|
||||
## 8. Milestone D — Work is steerable (Slice 8)
|
||||
|
||||
### Issue order
|
||||
|
||||
1. Question/Decision lifecycle.
|
||||
2. harness permission/question normalization.
|
||||
3. attention UI.
|
||||
4. contextual answer routing.
|
||||
5. same-session resume and restarted-session recovery.
|
||||
6. stale-question/version protections.
|
||||
|
||||
### Release gate
|
||||
|
||||
A deliberate ambiguity blocks Work, receives an answer, and resumes without duplicate execution.
|
||||
|
||||
## 9. Milestone E — Work survives complexity (Slices 9–10)
|
||||
|
||||
### Issue order
|
||||
|
||||
1. multi-slice commit model.
|
||||
2. IntegrationActor/use case.
|
||||
3. conflict/overlap analysis.
|
||||
4. integrated verification.
|
||||
5. preview adapter.
|
||||
6. release/rollback policy.
|
||||
7. observation and WorkResult.
|
||||
8. incident/result-to-Signal loop.
|
||||
|
||||
### Release gate
|
||||
|
||||
Two slices pass independently, integrate on one SHA, publish preview, and produce an observed result.
|
||||
|
||||
## 10. Milestone F — System improvement (Slices 11–12)
|
||||
|
||||
### Issue order
|
||||
|
||||
1. post-run evaluation schema.
|
||||
2. learning synthesis.
|
||||
3. knowledge proposal/diff workflow.
|
||||
4. Kit registry/versioning.
|
||||
5. Kit compiler.
|
||||
6. tool/skill registry.
|
||||
7. role evaluation harness.
|
||||
8. controlled parallel fleet.
|
||||
9. policy fallback to static Kit.
|
||||
|
||||
### Release gate
|
||||
|
||||
A completed run proposes a reviewable Kit/knowledge improvement, and a second run demonstrably benefits.
|
||||
|
||||
## 11. Actor rollout
|
||||
|
||||
### Initial
|
||||
|
||||
```text
|
||||
ProjectActor
|
||||
WorkActor
|
||||
AttemptActor
|
||||
```
|
||||
|
||||
WorkActor contains Resolver state and verification orchestration initially.
|
||||
|
||||
### Split only when justified
|
||||
|
||||
```text
|
||||
VerificationActor — independent check lifecycle becomes complex
|
||||
IntegrationActor — multi-branch/slice composition exists
|
||||
ResultActor — deployment observation exists
|
||||
ResolverActor — scheduling lifecycle needs independent ownership
|
||||
```
|
||||
|
||||
Avoid actor-per-record designs.
|
||||
|
||||
## 12. Test architecture
|
||||
|
||||
### Domain
|
||||
|
||||
- transition tables;
|
||||
- invariants;
|
||||
- retry/budget logic;
|
||||
- dependency graph readiness;
|
||||
- approval/version invalidation;
|
||||
- completion rules.
|
||||
|
||||
### Application
|
||||
|
||||
- command → event → projection;
|
||||
- idempotent side effects;
|
||||
- crash/restart recovery;
|
||||
- timeout/cancellation;
|
||||
- stale command rejection.
|
||||
|
||||
### Adapter contract tests
|
||||
|
||||
Run same suite against fake and live adapters:
|
||||
|
||||
```text
|
||||
HarnessRuntime
|
||||
SandboxRuntime
|
||||
SourceControl
|
||||
ArtifactStore
|
||||
```
|
||||
|
||||
### End-to-end
|
||||
|
||||
Maintain fixtures:
|
||||
|
||||
```text
|
||||
happy path
|
||||
casual message/no Work
|
||||
duplicate message
|
||||
definition revision
|
||||
design rejection
|
||||
harness transient failure
|
||||
human question/resume
|
||||
verification repair
|
||||
retry exhaustion
|
||||
PR duplicate callback
|
||||
actor restart mid-attempt
|
||||
cancellation
|
||||
```
|
||||
|
||||
### Quality evaluation
|
||||
|
||||
Before adding a new model/role/Kit, compare:
|
||||
|
||||
- success rate;
|
||||
- escaped defect rate;
|
||||
- review changes requested;
|
||||
- retries;
|
||||
- human attention;
|
||||
- cost/time;
|
||||
- design deviation.
|
||||
|
||||
## 13. Operational rollout
|
||||
|
||||
### Local
|
||||
|
||||
- fake adapters;
|
||||
- local Rivet/AgentOS;
|
||||
- one local repository fixture.
|
||||
|
||||
### Internal VPS
|
||||
|
||||
- deployed Rivet actors;
|
||||
- Bun/Effect daemon;
|
||||
- private Cube API;
|
||||
- one OMP template;
|
||||
- test forge repository.
|
||||
|
||||
### Dogfood repository
|
||||
|
||||
- real branch/PR;
|
||||
- manual review;
|
||||
- no production deploy;
|
||||
- collect every failure.
|
||||
|
||||
### Controlled release
|
||||
|
||||
- one project/user;
|
||||
- quotas;
|
||||
- kill switch;
|
||||
- audit trail;
|
||||
- explicit external side-effect gates.
|
||||
|
||||
## 14. Security checklist before real repositories
|
||||
|
||||
```text
|
||||
private control-plane networking
|
||||
Cube/Rivet authentication
|
||||
short-lived Git/model credentials
|
||||
sandbox egress policy
|
||||
no host HOME mounts
|
||||
tool allowlist
|
||||
artifact authorization
|
||||
secret redaction
|
||||
attempt timeout/cleanup
|
||||
manual merge/deploy
|
||||
audit approvals
|
||||
```
|
||||
|
||||
## 15. Branch/PR sequencing
|
||||
|
||||
Suggested integration branch:
|
||||
|
||||
```text
|
||||
dogfood/v0
|
||||
```
|
||||
|
||||
Per-slice branches:
|
||||
|
||||
```text
|
||||
dogfood/s01-work
|
||||
dogfood/s02-definition
|
||||
...
|
||||
```
|
||||
|
||||
Within a slice, parallel branches may cover:
|
||||
|
||||
```text
|
||||
contracts/domain
|
||||
backend/actor
|
||||
frontend
|
||||
adapter
|
||||
tests
|
||||
```
|
||||
|
||||
Merge order:
|
||||
|
||||
```text
|
||||
contracts → domain/actor → adapters/UI → integration tests
|
||||
```
|
||||
|
||||
Every branch must target the current slice integration branch, not independently invent shared schemas.
|
||||
|
||||
## 16. First backlog
|
||||
|
||||
Execute exactly in this order:
|
||||
|
||||
```text
|
||||
01 glossary + schemas
|
||||
02 Work/Attempt state machines
|
||||
03 commands/events/idempotency
|
||||
04 fake E2E harness
|
||||
05 message persistence
|
||||
06 Signal extraction
|
||||
07 Work card
|
||||
08 Work Definition
|
||||
09 definition approval
|
||||
10 Design Packet
|
||||
11 vertical-slice planner
|
||||
12 design approval
|
||||
13 Resolver with fake harness
|
||||
14 AttemptActor
|
||||
15 CubeSandbox adapter
|
||||
16 OMP adapter/spike
|
||||
17 real repository mutation
|
||||
18 VerificationRuntime
|
||||
19 repair loop
|
||||
20 Git commit/push/PR
|
||||
21 review package
|
||||
22 contextual question/resume
|
||||
23 integration verification
|
||||
24 preview/release/result
|
||||
25 learning proposals
|
||||
26 dynamic Kit Builder
|
||||
```
|
||||
|
||||
## 17. First production-quality acceptance test
|
||||
|
||||
```text
|
||||
Given:
|
||||
- one configured project/repository;
|
||||
- one actionable user message;
|
||||
- no pre-existing Work.
|
||||
|
||||
When:
|
||||
- Zopu processes the message;
|
||||
- the user approves definition/design;
|
||||
- the Resolver executes all slices;
|
||||
- verification passes;
|
||||
- publication is requested.
|
||||
|
||||
Then:
|
||||
- one Signal and one Work exist;
|
||||
- exact provenance is preserved;
|
||||
- every attempt has a terminal outcome;
|
||||
- one verified candidate SHA exists;
|
||||
- all required checks bind to that SHA;
|
||||
- one PR exists at that SHA;
|
||||
- the Work card explains intent, design, changes, evidence, and next action;
|
||||
- actor/process restarts produce no duplicate Work, attempts, commits, or PRs.
|
||||
```
|
||||
|
||||
## 18. Stop conditions
|
||||
|
||||
Pause feature expansion when any is true:
|
||||
|
||||
- stale “running” Work exists;
|
||||
- duplicate external side effects occur;
|
||||
- verification cannot identify exact SHA/environment;
|
||||
- human cannot understand why a Work is “done”;
|
||||
- retries are unbounded;
|
||||
- agents bypass actor/application commands;
|
||||
- provider-specific assumptions leak into domain;
|
||||
- new roles add cost without measured quality improvement.
|
||||
|
||||
Fix the completion system before adding more autonomy.
|
||||
1281
docs/dogfood-plan.md
1281
docs/dogfood-plan.md
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user