60 Commits

Author SHA1 Message Date
-Puter
52b90825a1 ax skills 2026-08-01 22:53:06 +05:30
-Puter
6364c33499 updates 2026-08-01 22:45:22 +05:30
-Puter
db9afd7a24 fix(lint): resolve all lint errors in github search and settings UI
- Merge duplicate react imports in repository-selector
- Use <dialog open> instead of role=dialog in settings popover
- Export type re-export from source module in use-github-repo-search
- Sort action args alphabetically in searchGithubRepositories
- Disable react-compiler rule for synchronous setState in debounced search
- Simplify getActiveGithubConnection query
2026-08-01 20:38:57 +05:30
-Puter
ce16813fbc feat(github): live repo search with /user/repos pagination and connection gear UI
- searchGithubRepositories action fetches up to 300 repos via paginated
  /user/repos, filters by name, upserts results into gitRepositories so
  createProjectFromRepository works unchanged; token never leaves backend
- connectGithub now persists grantedScopesJson from getAccessToken().scopes
- listProviderAccounts and list queries return grantedScopesJson
- Gear/settings popover on connected GitHub chip shows scopes and triggers
  linkSocial with expanded scope set [repo, read:org, read:user, user:email]
- RepositorySelector merges debounced live search results with synced snapshot
- Restored CONVEX_SITE_URL env var (removed in prior commit but still used)
2026-08-01 20:33:55 +05:30
-Puter
95c23db4fc feat(web): debounced github repo search and connection settings
Search hook calls gitConnections:searchGithubRepositories with the
stored encrypted credential (token never reaches the browser).
Connection settings popover shows granted scopes and drives
reauthorization with the expanded scope set.
2026-08-01 20:18:27 +05:30
-Puter
08bc5ae259 feat(web): vercel deployment and vds compose/systemd setup
Add Vercel config with React Router preset, vercel-build script and
/api/auth rewrite to the Convex site. Add VDS staging compose for the
Rivet engine and runner plus systemd units, deployment plan, and
Docker/Vercel ignore rules. Lockfile covers both the web and agents
dependency additions.
2026-08-01 20:18:09 +05:30
-Puter
4dc878b8cb fix(agents): bundle workspace deps into flue runtime
Replace @code/env and @code/primitives imports with local modules so
the Node deploy artifact does not resolve workspace packages to
TypeScript source at runtime. Switch Docker build to pnpm frozen
install, add runner Dockerfile and a liveness health endpoint.
2026-08-01 20:17:49 +05:30
-Puter
3e3f06403f fix(backend): github connection identity and live repo search
Resolve identity in actions and pass organizationId into sub-queries,
since runQuery from an action does not propagate auth. Add live GitHub
repository search backed by the stored encrypted credential, persisting
results into gitRepositories. Webhook now targets CONVEX_SITE_URL
directly instead of the web origin.
2026-08-01 20:17:45 +05:30
-Puter
8bd3953440 flue flow fixes 2026-07-31 23:43:49 +05:30
-Puter
bb4bd9e0d3 .ts import fixees 2026-07-31 21:00:47 +05:30
-Puter
f208d4e4b8 projects layout 2026-07-31 20:52:43 +05:30
-Puter
fc1d0b6916 github 0auth fixes 2026-07-31 19:29:00 +05:30
-Puter
fc8cfabacb refactor projects page 2026-07-31 18:53:04 +05:30
-Puter
ce1f33aa69 feat: consolidate agent execution stack 2026-07-31 18:26:41 +05:30
-Puter
c644ec8d01 feat(git): thin project onboarding, provider integration, and AgentOS repository access
Effect primitives:
- git-provider: GitProvider, connection states, normalized errors, URL
  normalization, host compatibility, credential freshness window
- git-provisioning: validated Puter commands, migration states, idempotency
  keys, safe replacement rules, owner-safe guards
- git-webhook: supported events, signature verification, delivery states
- host-repository: provider-neutral credential-safe clone via GIT_ASKPASS

Normalized Convex schema:
- gitProviderAccounts, refined gitConnections, gitProviderOrganizations,
  gitRepositories, gitMigrations, gitWebhookDeliveries
- projects: gitRepositoryId + instructions fields
- Schema fields optional for backward compatibility, with backfill cron

Backend:
- Connection health: verify action, hourly reconciliation (covers stale
  active + reauth-required + undefined-state legacy connections)
- Puter provisioning: createPuterUser/Organization/Repository with owner
  binding, startGithubMigration (durable via scheduler), getMigration
- Org ownership: explicit member add with admin role + verification
- Webhook HTTP actions: HMAC verification, delivery persistence with
  idempotency, repository resolution, byte-length payload limit
- Automatic Puter webhook creation after repo creation/migration with
  fail-loud state tracking
- Repository sync after connection (Gitea + GitHub)
- AgentOS execution resolves gitRepositoryId for real clone URL
- Credential gating: state + freshness checks before execution and
  project creation
- listForOrganization for cross-project Work filtering

Frontend:
- /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT
- Zero-project redirect, repository selection, context editor
- Provider-aware settings panel (no serverUrl for Puter)
- Project selection via ?project= query param
- GitHub scopes: repo + read:org

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
2026-07-31 14:36:56 +05:30
-Puter
88f53f550d Zopu Agent Tool Fixing 2026-07-31 01:31:12 +05:30
-Puter
54a5993274 Minor iOS phone fix 2026-07-31 01:30:59 +05:30
-Puter
def3fc949a doc 2026-07-31 01:30:45 +05:30
-Puter
9f7dd3c1a6 fuck them garbage deploys 2026-07-29 20:49:52 +05:30
-Puter
a8b2ff5e2e feat(agents): add 2026-07-29 20:11:08 +05:30
-Puter
40e0f7e1eb fix(workspace): ensure chat markdown readability on forced-dark surface 2026-07-29 19:34:20 +05:30
-Puter
bf2300a6be feat: same-origin auth via reverse proxy and secure cookies
Route /api/auth through the same origin in both dev and prod so
cookies stay first-party and the browser and React Router SSR share
one auth surface.

- Vite dev server proxies /api/auth to the Convex HTTP site.
- Better Auth uses secure cookies when the site URL is https.
- Add docs/auth-proxy.md documenting the required production ingress
  (Caddy/Traefik) and Convex SITE_URL / CONVEX_SITE_URL setup.
2026-07-29 19:32:26 +05:30
-Puter
0657037c84 feat: make Rivet endpoint optional for self-hosted runtime
RIVET_ENDPOINT and RIVET_PUBLIC_ENDPOINT are now optional in the agent
env schema; empty strings are coerced to undefined. The agent runtime
only passes an endpoint override to the Rivet client when one is set,
so the client falls back to its default (suitable for self-hosted
Rivet where no explicit endpoint is required).
2026-07-29 19:32:22 +05:30
-Puter
64a783b445 build: allow importing .ts extensions in Convex tsconfig
Enable allowImportingTsExtensions in the Convex tsconfig so the
backend code that uses explicit .ts import specifiers type-checks
cleanly.
2026-07-29 19:32:18 +05:30
-Puter
ed28943e7a fix: drop .ts extensions from primitives imports
Use extensionless import specifiers in project.ts and work.ts so the
modules resolve under Node ESM and bundler resolution, consistent with
allowImportingTsExtensions on the consuming packages.
2026-07-29 19:32:15 +05:30
-Puter
9e148489f0 chore: stop hiding repos/ in VS Code file explorer
Drop the files.exclude rule for repos/**. The directory is still
excluded from auto-imports and file watching, but no longer hidden
from the explorer.
2026-07-29 19:32:10 +05:30
-Puter
25f86d94cc chore: broaden .env ignore patterns
Add .env.* to .gitignore so per-environment secrets like
.env.staging and .env.production are never tracked, alongside the
existing .env and .env*.local rules.
2026-07-29 19:32:05 +05:30
-Puter
7668fa69cc fix: route web auth through same origin 2026-07-29 17:01:22 +05:30
-Puter
3ffa1cfc7c feat: project durable agent conversations 2026-07-29 08:19:17 +05:30
-Puter
18eb150d7d fix: serve web assets from app directory 2026-07-29 08:13:52 +05:30
-Puter
e1b0b731e0 deploy: attach backend to shared network 2026-07-29 08:09:30 +05:30
-Puter
d428a2492b fix: make primitives Node ESM compatible 2026-07-29 07:51:07 +05:30
-Puter
fe0fd9b16c deploy: enable Node TypeScript export resolution 2026-07-29 07:40:44 +05:30
-Puter
fc1fcf5d44 deploy: run agent backend with Bun 2026-07-29 07:31:36 +05:30
-Puter
3ae72864bd deploy: target agent package during Flue build 2026-07-29 07:21:02 +05:30
-Puter
0d5d54caa8 deploy: build agent without local env file 2026-07-29 07:15:19 +05:30
-Puter
830bcc4756 deploy: build applications on Node base 2026-07-29 07:09:07 +05:30
-Puter
0e56a462cd deploy: prefer Node for native install scripts 2026-07-29 07:03:32 +05:30
-Puter
9fb293a539 deploy: build native modules with Node 2026-07-29 07:00:47 +05:30
-Puter
062c00f53c deploy: install frontend build toolchain 2026-07-29 06:58:04 +05:30
-Puter
a7e70c9b2a deploy: install backend build toolchain 2026-07-29 06:56:36 +05:30
-Puter
526ed59776 deploy: fix compose build contexts 2026-07-29 06:46:07 +05:30
-Puter
fd3980c6bf deploy: split Dokploy services 2026-07-29 06:42:06 +05:30
-Puter
d8a4bbe804 chore cleanp 2026-07-29 01:24:37 +05:30
-Puter
9eb6bcd25f refactor canonical components and remove slice 1 2026-07-29 01:24:16 +05:30
-Puter
a907539810 flue refactor and migrations 2026-07-29 00:45:46 +05:30
-Puter
601aca73c2 pnpm migrations 2026-07-29 00:45:33 +05:30
-Puter
ffecff3857 Run fixed Zopu worktrees with Pi 2026-07-28 22:20:42 +05:30
-Puter
0d7162544b fix: run AgentOS workspaces on remote runner 2026-07-28 20:08:46 +05:30
-Puter
092a9793ea fix: resolve repository hosts outside AgentOS 2026-07-28 18:30:08 +05:30
-Puter
5ee0a8d50e fix: allow AgentOS process environment 2026-07-28 18:27:16 +05:30
-Puter
420676f2d7 fix: allow AgentOS command execution 2026-07-28 18:21:31 +05:30
-Puter
24d82e2a06 fix: allow AgentOS workspace filesystem 2026-07-28 18:18:24 +05:30
-Puter
d47fa0e96a fix: allow slow AgentOS workspace boot 2026-07-28 18:14:32 +05:30
-Puter
1e7c893985 fix: apply AgentOS runtime permissions 2026-07-28 17:49:41 +05:30
-Puter
f9ebcb4a01 fix: align AgentOS runtime packages 2026-07-28 17:40:40 +05:30
-Puter
dceaa2b417 fix: allow AgentOS outbound networking 2026-07-28 17:29:47 +05:30
-Puter
a4f121e190 chore: preserve work attempt schema order 2026-07-28 17:12:54 +05:30
-Puter
4edd456b5b feat: wire real AgentOS execution 2026-07-28 16:52:49 +05:30
-Puter
4d9f6da41b fix: install AgentOS Codex session adapter 2026-07-28 16:09:34 +05:30
237 changed files with 41539 additions and 12007 deletions

View 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.

View 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).

View 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.

View 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.

View 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.

View 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`.

View 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.

View 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.

View 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.

View 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()`.

View 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.

View 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`

View 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

View 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`.

View 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.

View 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.

View 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
View 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

View File

@@ -17,11 +17,15 @@ DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
# RIVET_ENDPOINT=http://localhost:6420
RIVET_ENDPOINT=http://localhost:6420
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token
FLUE_URL=http://localhost:3583
FLUE_URL=http://127.0.0.1:3585
# Agent model provider
AGENT_MODEL_PROVIDER=xiaomi

22
.gitignore vendored
View File

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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
verify-deps-before-run=false

17
.vercelignore Normal file
View File

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

View File

@@ -1,9 +1,6 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"files.exclude": {
"repos/**": true
},
"files.watcherExclude": {
"repos/**": true
},

36
apps/web/Dockerfile Normal file
View 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"]

View File

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

View File

@@ -1,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;

View File

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

View File

@@ -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>
);
};

View 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}/projects?resume=github`,
provider: "github",
});
} finally {
setPending(false);
}
};
return (
<button
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
onClick={linkGithub}
type="button"
>
<GitBranch className="size-5 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
</div>
{pending ? (
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
) : null}
</button>
);
};

View File

@@ -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}/projects?resume=github`,
provider: "github",
// Pass the full desired scope set so GitHub grants everything.
scopes: [...GITHUB_EXPANDED_SCOPES],
});
} catch {
setReauthorizing(false);
}
};
// Silence unused-vars: connectionId identifies which row is being managed.
void connectionId;
return (
<div className="relative" ref={popoverRef}>
<Button
aria-label="GitHub connection settings"
aria-haspopup="dialog"
aria-expanded={open}
className="h-8 w-8 gap-1 rounded border-[#d7d3c7] bg-[#fffefa] px-0 text-[#747168] hover:bg-[#f5f3eb]"
disabled={reauthorizing}
onClick={() => setOpen((value) => !value)}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="size-3.5" />
</Button>
{open ? (
<dialog
aria-modal="false"
className="absolute top-9 right-0 z-50 w-72 rounded-lg border border-[#d7d3c7] bg-[#fffefa] p-4 shadow-xl"
open
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-4 text-emerald-500" />
<h4 className="text-sm font-semibold text-[#20201d]">
GitHub Access
</h4>
</div>
<button
aria-label="Close settings"
className="text-[#858277] hover:text-[#20201d]"
onClick={() => setOpen(false)}
type="button"
>
<X className="size-3.5" />
</button>
</div>
{externalUsername ? (
<p className="mt-2 text-xs text-[#68665e]">
Connected as{" "}
<span className="font-medium text-[#20201d]">
{externalUsername}
</span>
</p>
) : null}
{grantedScopes.length > 0 ? (
<div className="mt-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-[#858277]">
Granted scopes
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{grantedScopes.map((scope) => (
<span
className="rounded bg-[#f5f3eb] px-1.5 py-0.5 text-[10px] font-medium text-[#68665e]"
key={scope}
>
{scope}
</span>
))}
</div>
</div>
) : (
<p className="mt-2 text-xs text-[#858277]">
Scope details will appear after reauthorization.
</p>
)}
<p className="mt-3 text-xs leading-5 text-[#68665e]">
Reauthorization refreshes GitHub scopes and re-syncs your accessible
repositories, including organization repos GitHub returns for your
account.
</p>
<p className="mt-2 text-[11px] leading-4 text-[#858277]">
Organization repository visibility depends on your GitHub org
membership and SSO authorization not all org repos may be
accessible via OAuth alone.
</p>
<Button
className="mt-3 w-full gap-1.5 rounded border-[#55564e] bg-[#20201d] text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
disabled={reauthorizing}
onClick={() => void handleReauthorize()}
size="sm"
type="button"
variant="outline"
>
{reauthorizing ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{reauthorizing ? "Redirecting…" : "Reauthorize access"}
</Button>
{missingScopes.length > 0 ? (
<p className="mt-2 text-[10px] leading-4 text-amber-600">
Not yet granted: {missingScopes.join(", ")}
</p>
) : null}
</dialog>
) : null}
</div>
);
};

View File

@@ -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>
);

View 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>
);
};

View 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]">
Choose a repository to continue its project conversation.
</p>
</div>
<label className="relative block w-full sm:w-64">
<span className="sr-only">Search projects</span>
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search projects"
type="search"
value={search}
/>
</label>
</div>
{projects.length > 0 ? (
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
) : (
<div className="mt-5 border border-dashed border-[#c9c5b9] bg-[#fffefa]/60 p-8 text-center">
<p className="text-sm font-medium text-[#20201d]">
No matching projects
</p>
<p className="mt-1 text-xs text-[#747168]">
Try a different project name or repository.
</p>
</div>
)}
</section>
);

View File

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

View File

@@ -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}/projects?resume=github`,
provider: "github",
});
} finally {
setPendingProvider(undefined);
}
};
const handlePuterChipClick = () => {
if (puterAccount && isHealthy(puterAccount.status)) {
return;
}
setShowPuterForm((value) => !value);
};
const handlePuterConnected = () => {
setShowPuterForm(false);
};
const accountsLoading = accounts === undefined;
const githubConnected =
githubAccount !== undefined && isHealthy(githubAccount.status);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<ProviderChip
account={githubAccount}
icon={GitBranch}
label="GitHub"
loading={accountsLoading || pendingProvider === "github"}
onClick={linkGithub}
/>
{githubConnected ? (
<GithubConnectionSettings
connectionId={githubAccount.id}
externalUsername={githubAccount.externalUsername}
grantedScopesJson={githubAccount.grantedScopesJson}
/>
) : null}
</div>
<ProviderChip
account={puterAccount}
icon={Server}
label="Puter Git"
loading={accountsLoading || pendingProvider === "gitea"}
onClick={handlePuterChipClick}
/>
</div>
{showPuterForm ? (
<div className="rounded border border-[#d7d3c7] bg-[#fffefa] p-3 shadow-sm">
<PuterConnectForm onConnected={handlePuterConnected} />
</div>
) : null}
</div>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

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

View File

@@ -1,789 +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,
Settings,
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 ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
{latestRun.executionKind === "real"
? "AgentOS"
: "Simulation"}{" "}
run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
</p>
{latestRun.attemptEvents?.slice(-5).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.baseRevision ? (
<p className="font-mono text-[10px] text-[#747168]">
{latestRun.baseRevision.slice(0, 8)} {" "}
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
</p>
) : null}
{latestRun.artifacts?.map((artifact) => (
<a
className="block font-medium underline"
href={artifact.uri}
key={artifact._id}
rel="noreferrer"
target="_blank"
>
{artifact.title}
</a>
))}
</div>
) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<Button
size="sm"
disabled={!slice.projectGitConnection}
onClick={() =>
void slice.startExecution(work._id, design?.slices?.[0]?.id)
}
>
<Play className="size-3.5" /> Run
</Button>
) : null}
{work.status === "ready" ? (
<Button
size="sm"
variant="outline"
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 (latestRun.executionKind === "real"
? slice.cancelExecution(latestRun._id)
: 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 [settingsOpen, setSettingsOpen] = useState(false);
const [giteaUrl, setGiteaUrl] = useState("https://git.openputer.com");
const [giteaUsername, setGiteaUsername] = useState("");
const [giteaToken, setGiteaToken] = useState("");
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="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
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
aria-label="Project settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
>
<Settings className="size-4" />
</Button>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Project Git</h2>
<button
aria-label="Close settings"
onClick={() => setSettingsOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</div>
<p className="mt-1 text-xs text-[#747168]">
{slice.projectGitConnection
? `${slice.projectGitConnection.provider} · ${slice.projectGitConnection.serverUrl}`
: "No Git credentials attached"}
</p>
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void slice.authorizeGithub()}
>
Authorize GitHub
</Button>
<Button
size="sm"
onClick={() => void slice.connectLinkedGithub()}
>
Use GitHub
</Button>
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaUrl(event.target.value)}
value={giteaUrl}
/>
<input
aria-label="Gitea username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaUsername(event.target.value)}
placeholder="Username (optional)"
value={giteaUsername}
/>
<input
aria-label="Gitea access token"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaToken(event.target.value)}
placeholder="Personal access token"
type="password"
value={giteaToken}
/>
<Button
className="w-full"
disabled={!giteaToken.trim()}
size="sm"
onClick={() => {
void slice.connectGitea({
serverUrl: giteaUrl,
token: giteaToken,
username: giteaUsername || undefined,
});
setGiteaToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
) : null}
</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>
);
};

View File

@@ -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";

View File

@@ -0,0 +1,112 @@
import { Button } from "@code/ui/components/button";
import { ImagePlus, LoaderCircle, Send } from "lucide-react";
import { useRef } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ConversationComposer = ({
draft,
onDraftChange,
workspace,
}: {
readonly draft: string;
readonly onDraftChange: (draft: string) => void;
readonly workspace: WorkspaceState;
}) => {
const imageInput = useRef<HTMLInputElement>(null);
const attachments = useChatImages();
const busy =
workspace.agent.status === "submitted" ||
workspace.agent.status === "streaming";
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
await workspace.agent.sendMessage(message, {
images: attachments.images.map((image) => image.file),
});
onDraftChange("");
attachments.clear();
};
return (
<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}
{workspace.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{workspace.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-base outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => onDraftChange(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>
);
};

View File

@@ -0,0 +1,108 @@
import { projectWorkNotices } from "@code/primitives/work";
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { MessageSquareText } from "lucide-react";
import { useMemo } from "react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { buildWorkspaceTimeline } from "@/lib/workspace/presentation";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
import { WorkCard } from "./work-card";
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>
);
export const ConversationFeed = ({
highlightedMessageId,
onSourceSelect,
workspace,
}: {
readonly highlightedMessageId?: string;
readonly onSourceSelect: (rawText: string) => void;
readonly workspace: WorkspaceState;
}) => {
const works = workspace.works ?? [];
const workById = new Map(
works.map((work) => [String(work._id), work] as const)
);
const notices = projectWorkNotices(works);
const timeline = useMemo(
() => buildWorkspaceTimeline(workspace.agent.messages, notices),
[notices, workspace.agent.messages]
);
return (
<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">
{!workspace.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{workspace.agent.historyReady && timeline.length === 0 ? (
<ConversationEmptyState />
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId) as
| WorkRecord
| undefined;
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>
<div className="rounded-sm">
<span className="sr-only">Proposed Work</span>
<WorkCard
onSourceSelect={onSourceSelect}
work={work}
workspace={workspace}
/>
</div>
</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={`workspace-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage message={item.message} />
</div>
);
})}
{workspace.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,50 @@
import { Button } from "@code/ui/components/button";
import { FolderGit2, LoaderCircle } from "lucide-react";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectConnectForm = ({
workspace,
}: {
readonly workspace: WorkspaceState;
}) => (
<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 workspace.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 a project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Turn actionable project 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) => workspace.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={workspace.repository}
/>
{workspace.error ? (
<p className="mt-2 text-xs text-red-700">{workspace.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={workspace.pending}
type="submit"
>
{workspace.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{workspace.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);

View File

@@ -0,0 +1,51 @@
import { FolderGit2, Menu } from "lucide-react";
import { Link } from "react-router";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectHeader = ({
onOpenDrawer,
workspace,
}: {
readonly onOpenDrawer: () => void;
readonly workspace: WorkspaceState;
}) => {
const works = workspace.works ?? [];
return (
<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) => workspace.selectProject(event.target.value)}
value={workspace.selectedProject?.id ?? ""}
>
{(workspace.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>
<Link
aria-label="Projects"
className="mr-2 grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
to="/projects"
>
<FolderGit2 className="size-4" />
</Link>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={onOpenDrawer}
type="button"
>
<Menu className="size-4" /> Work{" "}
{workspace.works === undefined ? "…" : works.length}
</button>
</header>
);
};

View File

@@ -0,0 +1,123 @@
import { useRef, useState } from "react";
import { useProjectWorkspace } from "@/hooks/workspace/use-project-workspace";
import { useVisualViewportStyle } from "@/hooks/workspace/use-visual-viewport";
import { findSourceMessageTarget } from "@/lib/workspace/presentation";
import { ConversationComposer } from "./conversation-composer";
import { ConversationFeed } from "./conversation-feed";
import { ProjectConnectForm } from "./project-connect-form";
import { ProjectHeader } from "./project-header";
import { WorkFeed } from "./work-feed";
const ProjectLoading = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<span className="size-5 animate-spin rounded-full border-2 border-[#20201d] border-t-transparent" />
</main>
);
export const ProjectWorkspacePage = () => {
const workspace = useProjectWorkspace();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
if (workspace.projects === undefined) {
return <ProjectLoading />;
}
if (!workspace.selectedProject) {
return <ProjectConnectForm workspace={workspace} />;
}
const works = workspace.works ?? [];
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(
workspace.agent.messages,
rawText
);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() =>
document
.querySelector(`#workspace-message-${CSS.escape(messageId)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" })
);
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
return (
<main
className="workspace-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">
<ProjectHeader
onOpenDrawer={() => setDrawerOpen(true)}
workspace={workspace}
/>
<ConversationFeed
highlightedMessageId={highlightedMessageId}
onSourceSelect={revealSourceMessage}
workspace={workspace}
/>
<ConversationComposer
draft={draft}
onDraftChange={setDraft}
workspace={workspace}
/>
</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>
<WorkFeed
onSourceSelect={revealSourceMessage}
works={works}
workspace={workspace}
/>
</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"
>
×
</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
<WorkFeed
onSourceSelect={revealSourceMessage}
works={works}
workspace={workspace}
/>
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -0,0 +1,343 @@
import { Button } from "@code/ui/components/button";
import {
AlertTriangle,
ChevronRight,
FileCode2,
Hammer,
Play,
RotateCcw,
ScrollText,
Sparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { changedFilesFor } from "@/lib/workspace/types";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: WorkRecord;
readonly workspace: WorkspaceState;
}
const starterDefinition = (work: WorkRecord) => ({
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: ["Implementation diff and terminal outcome"],
risk: "medium",
});
const starterDesign = (work: WorkRecord) => ({
architectureSummary: "Implement the requested outcome in focused changes.",
callFlowDelta: ["Work -> Run -> Attempt -> AgentOS -> candidate revision"],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
impactMap: {
files: [],
modules: [],
risks: [],
summary: "Focused implementation",
},
invariants: [
"A successful AgentOS response alone never marks Work complete",
"Every Attempt reaches a terminal classification",
],
keyInterfaces: ["WorkExecution", "AttemptResult"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
id: "implementation",
objective: work.objective,
observableBehavior: "Implementation changes are visible in the diff",
reviewRequired: false,
title: "Implementation",
verification: ["Run completes with a terminal classification"],
},
],
tradeoffs: [],
});
// oxlint-disable-next-line complexity -- this card coordinates planning and run evidence.
export const WorkCard = ({
onSourceSelect,
work,
workspace,
}: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
const [latestRun] = work.runs;
const openQuestions =
work.definition?.questions?.filter((question) => question.status === "open")
.length ?? 0;
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]">
{work.status === "proposed" ? "Proposed Work" : "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"
>
<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]">Status: {work.status}</p>
<p className="mt-1 text-[#747168]">
Risk: {work.definition?.risk ?? "not defined"}
</p>
{openQuestions > 0 ? (
<p className="mt-1 text-amber-800">
{openQuestions} open question(s)
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "proposed" ? (
<Button
size="sm"
onClick={() => void workspace.requestDefinition(work._id)}
>
<Sparkles className="size-3.5" /> Define
</Button>
) : null}
{work.status === "defining" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.saveDefinition(
work._id,
starterDefinition(work)
)
}
>
<Hammer className="size-3.5" /> Save definition
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Design
</p>
<p className="mt-1 leading-5 text-[#626057]">
{work.design?.architectureSummary ?? "No Design Packet yet."}
</p>
{work.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 workspace.saveDesign(work._id, starterDesign(work))
}
>
<Hammer className="size-3.5" /> Save design
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Build
</p>
{workspace.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{workspace.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => workspace.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
{latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
Run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
</p>
{latestRun.baseRevision ? (
<p className="font-mono text-[10px] text-[#747168]">
{latestRun.baseRevision.slice(0, 8)} {" "}
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
</p>
) : null}
{latestRun.artifacts?.map((artifact) => (
<div key={artifact._id} className="space-y-1">
<p className="font-medium">
{artifact.uri ? (
<a
className="underline"
href={artifact.uri}
rel="noreferrer"
target="_blank"
>
{artifact.title}
</a>
) : (
artifact.title
)}
</p>
{changedFilesFor(artifact).length > 0 ? (
<ul className="space-y-0.5">
{changedFilesFor(artifact).map((file) => (
<li
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
key={file}
>
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
<span className="min-w-0 break-all">{file}</span>
</li>
))}
</ul>
) : null}
</div>
))}
{latestRun.attemptEvents &&
latestRun.attemptEvents.length > 0 ? (
<div className="space-y-1">
{(logsOpen
? latestRun.attemptEvents
: latestRun.attemptEvents.slice(-3)
).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2 leading-5"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.attemptEvents.length > 3 ? (
<button
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
onClick={() => setLogsOpen((open) => !open)}
type="button"
>
<ScrollText className="size-3.5" />
{logsOpen
? "Show recent activity"
: `Show full activity log (${latestRun.attemptEvents.length})`}
<ChevronRight
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
/>
</button>
) : null}
</div>
) : null}
</div>
) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<Button
size="sm"
disabled={!workspace.projectGitConnection}
onClick={() => void workspace.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
) : null}
{latestRun?.status === "running" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.cancelExecution(latestRun._id)}
>
<X className="size-3.5" /> Cancel
</Button>
) : null}
{latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.retryExecution(latestRun._id)}
>
<RotateCcw className="size-3.5" /> Retry
</Button>
) : null}
</div>
</section>
</div>
) : null}
</article>
);
};

View File

@@ -0,0 +1,29 @@
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
import { WorkCard } from "./work-card";
export const WorkFeed = ({
onSourceSelect,
works,
workspace,
}: {
readonly onSourceSelect: (rawText: string) => void;
readonly works: readonly WorkRecord[];
readonly workspace: WorkspaceState;
}) => (
<div className="space-y-3">
{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={onSourceSelect}
work={work}
workspace={workspace}
/>
))}
</div>
);

View File

@@ -55,7 +55,12 @@ export const useOrganizationChatAgent = (
const projected = projectConversation(rows ?? []);
let status: AgentStatus = projected.pending ? "submitted" : "idle";
let status: AgentStatus = "idle";
if (projected.streaming) {
status = "streaming";
} else if (projected.pending) {
status = "submitted";
}
if (!organizationId || rows === undefined) {
status = organization.error ? "error" : "connecting";
} else if (projected.failedError || sendError) {

View File

@@ -1,323 +0,0 @@
import { authClient } from "@code/auth/web";
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 {
readonly artifacts?: readonly {
_id: string;
kind: string;
metadataJson: string;
sourceRevision?: string;
title: string;
uri?: string;
}[];
readonly attemptEvents?: readonly {
_id: string;
kind: string;
message: string;
occurredAt: number;
}[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
_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");
const startExecutionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; sliceId?: string },
unknown
>("workExecutionWorkflow:startExecution");
const cancelExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecutionWorkflow:cancelExecution");
const listGitConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
readonly {
id: string;
provider: "github" | "gitea";
serverUrl: string;
username?: string;
}[]
>("gitConnectionData:list");
const projectGitConnectionRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
{
id: string;
provider: "github" | "gitea";
serverUrl: string;
username?: string;
} | null
>("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference<
"action",
{ serverUrl: string; token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
const attachGitConnectionRef = makeFunctionReference<
"mutation",
{ connectionId: Id<"gitConnections">; projectId: Id<"projects"> },
unknown
>("gitConnectionData:attachToProject");
const authorizeGithub = () =>
authClient.signIn.social({
callbackURL: window.location.href,
provider: "github",
});
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 gitConnections = useQuery(
listGitConnectionsRef,
organization.organizationId ? {} : "skip"
);
const projectGitConnection = useQuery(
projectGitConnectionRef,
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 startExecutionMutation = useMutation(startExecutionRef);
const cancelExecutionMutation = useMutation(cancelExecutionRef);
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
const connectGiteaAction = useAction(connectGiteaRef);
const connectGithubAction = useAction(connectGithubRef);
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 });
const startExecution = (workId: Id<"works">, sliceId?: string) =>
startExecutionMutation({ sliceId, workId });
const cancelExecution = (runId: Id<"workRuns">) =>
cancelExecutionMutation({ runId });
const attachConnection = async (connectionId: Id<"gitConnections">) => {
if (!activeProjectId) {
throw new Error("Select a project first");
}
await attachGitConnectionMutation({
connectionId,
projectId: activeProjectId,
});
};
const connectGitea = async (input: {
serverUrl: string;
token: string;
username?: string;
}) => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
};
const connectLinkedGithub = async () => {
const result = await connectGithubAction({});
await attachConnection(result.connectionId);
};
return {
agent,
approveDefinition,
approveDesign,
authorizeGithub,
cancelExecution,
cancelSimulation,
connectGitea,
connectLinkedGithub,
connectRepository,
error,
gitConnections,
pending,
projectGitConnection,
projects,
repository,
requestDefinition,
retrySimulation,
saveDefinition,
saveDesign,
selectProject,
selectedProject,
setRepository,
startExecution,
startSimulation,
works,
} as const;
};

View 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]);

View 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;
};

View 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,
};
};

View 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,
};
};

View File

@@ -0,0 +1,232 @@
import { authClient } from "@code/auth/web";
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 { useSearchParams } from "react-router";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
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 saveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDesignProposal");
const startExecutionRef = makeFunctionReference<
"mutation",
{ sliceId?: string; workId: Id<"works"> },
unknown
>("workExecution:start");
const cancelExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:cancel");
const retryExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:retry");
const listGitConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
readonly {
id: string;
provider: "github" | "gitea";
serverUrl: string;
username?: string;
}[]
>("gitConnectionData:list");
const projectGitConnectionRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
{
id: string;
provider: "github" | "gitea";
serverUrl: string;
username?: string;
} | null
>("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference<
"action",
{ token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
const attachGitConnectionRef = makeFunctionReference<
"mutation",
{ connectionId: Id<"gitConnections">; projectId: Id<"projects"> },
unknown
>("gitConnectionData:attachToProject");
export const useProjectWorkspace = (): WorkspaceState => {
const organization = usePersonalOrganization();
const projects = useQuery(
api.projects.list,
organization.organizationId ? {} : "skip"
);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useOrganizationChatAgent(organization);
const [searchParams] = useSearchParams();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>();
const [operationError, setOperationError] = useState<Error>();
const projectParam = searchParams.get("project");
const paramProject = projectParam
? (projectParam as unknown as Id<"projects">)
: null;
const selectedProjectStillExists = projects?.some(
(project) =>
project.id === (selectedProjectId as unknown as string) ||
project.id === (paramProject as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? (selectedProjectId ?? paramProject)
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
workListRef,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const gitConnections = useQuery(
listGitConnectionsRef,
organization.organizationId ? {} : "skip"
);
const projectGitConnection = useQuery(
projectGitConnectionRef,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const requestDefinitionMutation = useMutation(requestDefinitionRef);
const saveDefinitionMutation = useMutation(saveDefinitionRef);
const saveDesignMutation = useMutation(saveDesignRef);
const startExecutionMutation = useMutation(startExecutionRef);
const cancelExecutionMutation = useMutation(cancelExecutionRef);
const retryExecutionMutation = useMutation(retryExecutionRef);
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
const connectGiteaAction = useAction(connectGiteaRef);
const connectGithubAction = useAction(connectGithubRef);
const selectedProject = useMemo(
() =>
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null,
[activeProjectId, projects]
);
const runOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
setOperationError(undefined);
try {
return await operation();
} catch (caughtError) {
setOperationError(toError(caughtError));
throw caughtError;
}
};
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 attachConnection = async (connectionId: Id<"gitConnections">) => {
if (!activeProjectId) {
throw new Error("Select a project first");
}
await attachGitConnectionMutation({
connectionId,
projectId: activeProjectId,
});
};
const connectGitea = (input: { token: string; username?: string }) =>
runOperation(async () => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
});
const connectLinkedGithub = () =>
runOperation(async () => {
const result = await connectGithubAction({});
await attachConnection(result.connectionId);
});
return {
agent,
authorizeGithub: () =>
authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
}),
cancelExecution: (runId: Id<"workRuns">) =>
runOperation(() => cancelExecutionMutation({ runId })),
clearOperationError: () => setOperationError(undefined),
connectGitea,
connectLinkedGithub,
connectRepository,
error,
gitConnections,
operationError,
pending,
projectGitConnection,
projects,
repository,
requestDefinition: (workId: Id<"works">) =>
requestDefinitionMutation({ workId }),
retryExecution: (runId: Id<"workRuns">) =>
runOperation(() => retryExecutionMutation({ runId })),
saveDefinition: (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
saveDesign: (workId: Id<"works">, payload: unknown) =>
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }),
selectProject: (projectId: string) =>
setSelectedProjectId(projectId as unknown as Id<"projects">),
selectedProject: selectedProject
? { id: selectedProject.id, name: selectedProject.name }
: null,
setRepository,
startExecution: (workId: Id<"works">, sliceId?: string) =>
runOperation(() => startExecutionMutation({ sliceId, workId })),
works,
};
};

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Slice 1 visual viewport", () => {
describe("Workspace visual viewport", () => {
test("shrinks the application surface to the keyboard-visible height", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
height: "500px",

View File

@@ -33,7 +33,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
});
};
root.classList.add("slice-one-viewport-lock");
root.classList.add("workspace-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
@@ -41,7 +41,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("slice-one-viewport-lock");
root.classList.remove("workspace-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);

View File

@@ -6,8 +6,8 @@ body {
min-height: 100%;
}
html.slice-one-viewport-lock,
html.slice-one-viewport-lock body {
html.workspace-viewport-lock,
html.workspace-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
@@ -116,9 +116,42 @@ html.slice-one-viewport-lock body {
color: var(--foreground);
}
.slice-one-surface .chat-markdown,
.slice-one-surface .chat-reasoning,
.slice-one-surface .thinking-line {
/*
* The chat always renders on the light workspace surface
* (`bg-[#f2f0e7]`, near-black text), but the app forces the dark theme
* app-wide (`forcedTheme="dark"`). Streamdown's code/table/mermaid chrome and
* the rules below read theme tokens, which under `.dark` resolve to near-black
* values — producing unreadable dark-on-dark blocks. Re-declare the relevant
* tokens to warm-light values inside the markdown context so every token-driven
* chrome (headers, copy/download/fullscreen controls, table borders/cells,
* inline code) reads correctly on the light surface without touching the
* global theme or unrelated UI.
*/
.workspace-surface .chat-markdown {
--background: #ffffff;
--foreground: #232321;
--muted: #f4f4f2;
--muted-foreground: #69675f;
--card: #ffffff;
--card-foreground: #232321;
--popover: #ffffff;
--popover-foreground: #232321;
--secondary: #f4f4f2;
--secondary-foreground: #232321;
--accent: #efece4;
--accent-foreground: #232321;
--sidebar: #ffffff;
--sidebar-foreground: #232321;
--sidebar-accent: #f4f4f2;
--sidebar-accent-foreground: #232321;
--border: #e2e0d6;
--input: #e2e0d6;
--ring: #b6b7b4;
}
.workspace-surface .chat-markdown,
.workspace-surface .chat-reasoning,
.workspace-surface .thinking-line {
color: #232321;
}
@@ -223,6 +256,16 @@ html.slice-one-viewport-lock body {
text-align: start;
}
/* Readable header band + zebra rows so tables stand out on the surface. */
.chat-markdown thead th {
background: var(--muted);
font-weight: 600;
}
.chat-markdown tbody tr:nth-child(even) {
background: color-mix(in oklch, var(--muted) 55%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.chat-markdown *,
.chat-message,

View File

@@ -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 };

View File

@@ -20,7 +20,7 @@ describe("projectConversation", () => {
messageId: "user-1",
rawText: "Build it",
role: "user",
status: "processing",
status: "dispatching",
}),
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
]);
@@ -28,6 +28,21 @@ describe("projectConversation", () => {
expect(state.messages).toHaveLength(1);
});
test("projects partial assistant text as streaming", () => {
const state = projectConversation([
row({
messageId: "assistant-streaming",
rawText: "Working",
role: "assistant",
status: "running",
}),
]);
expect(state.streaming).toBe(true);
expect(state.messages[0]?.parts).toEqual([
{ state: "streaming", text: "Working", type: "text" },
]);
});
test("projects completed Convex rows into renderable messages", () => {
const state = projectConversation([
row({
@@ -41,4 +56,39 @@ describe("projectConversation", () => {
{ state: "done", text: "Captured the request.", type: "text" },
]);
});
test("clears a historical failure after a newer turn succeeds", () => {
const state = projectConversation([
row({
error: "Old admission failure",
messageId: "assistant-failed",
role: "assistant",
status: "failed",
}),
row({
messageId: "assistant-completed",
rawText: "Recovered",
role: "assistant",
status: "completed",
}),
]);
expect(state.failedError).toBeUndefined();
});
test("clears historical pending state after a newer turn completes", () => {
const state = projectConversation([
row({
messageId: "assistant-stale",
role: "assistant",
status: "running",
}),
row({
messageId: "assistant-completed",
rawText: "Recovered",
role: "assistant",
status: "completed",
}),
]);
expect(state.pending).toBe(false);
});
});

View File

@@ -11,15 +11,44 @@ export interface ConversationRow {
readonly messageId: string;
readonly rawText: string;
readonly role: "assistant" | "user";
readonly status: "completed" | "failed" | "processing" | "queued";
readonly status:
| "aborted"
| "completed"
| "dispatching"
| "failed"
| "queued"
| "running";
}
export const projectConversation = (rows: readonly ConversationRow[]) => ({
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
const latestAssistant = (
rows: readonly ConversationRow[]
): ConversationRow | undefined =>
rows.findLast((row) => row.role === "assistant");
const latestTerminalError = (row: ConversationRow | undefined) =>
row?.status === "failed"
? (row.error ?? "Conversation turn failed")
: undefined;
export const projectConversation = (rows: readonly ConversationRow[]) => {
const activeAssistant = latestAssistant(rows);
const streaming = rows.some(
(row) =>
row.role === "assistant" &&
row.status === "running" &&
row.rawText.length > 0
);
return {
failedError: latestTerminalError(activeAssistant),
messages: rows
.filter((row) => row.role === "user" || row.status === "completed")
.filter(
(row) =>
row.role === "user" ||
row.status === "completed" ||
(row.role === "assistant" &&
row.status === "running" &&
row.rawText.length > 0)
)
.map<ConversationMessage>((row) => ({
id: row.messageId,
parts: [
@@ -33,7 +62,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => ({
...(row.rawText
? [
{
state: "done" as const,
state:
row.status === "running"
? ("streaming" as const)
: ("done" as const),
text: row.rawText,
type: "text" as const,
},
@@ -42,9 +74,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => ({
],
role: row.role,
})),
pending: rows.some(
(row) =>
row.role === "assistant" &&
(row.status === "queued" || row.status === "processing")
),
});
pending:
activeAssistant?.status === "queued" ||
activeAssistant?.status === "dispatching" ||
activeAssistant?.status === "running",
streaming,
};
};

View File

@@ -5,27 +5,36 @@ import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Slice 1 frontend regression contracts", () => {
describe("Workspace frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const page = source(
"../../components/workspace/project-workspace-page.tsx"
);
const viewport = source("../../hooks/workspace/use-visual-viewport.ts");
const root = source("../../root.tsx");
const styles = source("../../index.css");
expect(root).toContain("interactive-widget=resizes-content");
expect(page).toContain("style={viewportStyle}");
expect(page).toContain("fixed inset-x-0 top-0");
expect(page).not.toContain("slice-one-surface flex h-svh");
expect(styles).toContain("html.slice-one-viewport-lock body");
expect(viewport).toContain("workspace-viewport-lock");
expect(styles).toContain("html.workspace-viewport-lock body");
expect(styles).toContain("overflow: hidden");
});
test("keeps the responsive shell shrinkable with a pinned composer", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const page = source(
"../../components/workspace/project-workspace-page.tsx"
);
const feed = source("../../components/workspace/conversation-feed.tsx");
const composer = source(
"../../components/workspace/conversation-composer.tsx"
);
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t');
expect(page).toContain(
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
expect(composer).toContain('className="shrink-0 border-t');
expect(composer).toContain(
'className="mx-auto flex max-w-2xl items-end gap-2"'
);
});
@@ -50,4 +59,20 @@ describe("Slice 1 frontend regression contracts", () => {
expect(messageRenderer).toContain("mermaid");
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
});
test("keeps markdown readable on the forced-dark workspace surface", () => {
const styles = source("../../index.css");
// The workspace surface is light, but the app forces the dark theme, so
// the markdown context must re-declare the theme tokens streamdown and the
// table/code rules read from. Without these the dark tokens (~22-28%
// lightness) produce near-black text, borders, and backgrounds.
expect(styles).toContain(".workspace-surface .chat-markdown");
expect(styles).toContain("--muted: #f4f4f2");
expect(styles).toContain("--border: #e2e0d6");
expect(styles).toContain("--sidebar: #ffffff");
// Table headers and zebra rows must stand out on the surface.
expect(styles).toContain(".chat-markdown thead th");
expect(styles).toContain(".chat-markdown tbody tr:nth-child(even)");
});
});

View File

@@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest";
import type { ConversationMessage } from "@/lib/chat/types";
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
import {
buildWorkspaceTimeline,
findSourceMessageTarget,
} from "./presentation";
const textMessage = (
id: string,
@@ -23,9 +26,9 @@ const notice: WorkNotice = {
workId: "work-1",
};
describe("Slice 1 presentation", () => {
describe("Workspace presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildSliceOneTimeline(
const timeline = buildWorkspaceTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
textMessage("assistant-1", "assistant", "Captured and proposed Work."),

View File

@@ -7,14 +7,9 @@ import {
} from "@/lib/chat/transforms";
import type { ConversationMessage } from "@/lib/chat/types";
export type SliceTimelineItem =
| {
readonly kind: "message";
readonly message: ConversationMessage;
}
| { readonly kind: "work"; readonly notice: WorkNotice };
import type { WorkspaceTimelineItem } from "./types";
export const isSliceOneVisibleMessage = (
export const isVisibleConversationMessage = (
message: ConversationMessage
): boolean =>
message.role === "user" ||
@@ -42,11 +37,11 @@ const targetIndexForNotice = (
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildSliceOneTimeline = (
export const buildWorkspaceTimeline = (
allMessages: readonly ConversationMessage[],
notices: readonly WorkNotice[]
): readonly SliceTimelineItem[] => {
const messages = allMessages.filter(isSliceOneVisibleMessage);
): readonly WorkspaceTimelineItem[] => {
const messages = allMessages.filter(isVisibleConversationMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
@@ -55,7 +50,7 @@ export const buildSliceOneTimeline = (
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: SliceTimelineItem[] = [];
const timeline: WorkspaceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {

View File

@@ -0,0 +1,140 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { WorkNotice } from "@code/primitives/work";
import type { ConversationMessage } from "@/lib/chat/types";
export interface WorkRecord {
readonly _id: Id<"works">;
readonly title: string;
readonly objective: string;
readonly status: string;
readonly definitionVersion?: number;
readonly designVersion?: number;
readonly signals: readonly {
readonly sources: readonly { messageId: string; rawText: string }[];
}[];
readonly runs: readonly WorkRun[];
readonly definition: {
readonly risk?: string;
readonly questions?: readonly { status: string }[];
} | null;
readonly design: {
readonly architectureSummary?: string;
readonly slices?: readonly {
readonly id: string;
readonly title: string;
readonly observableBehavior: string;
}[];
} | null;
}
export interface WorkRun {
readonly artifacts?: readonly WorkArtifact[];
readonly attemptEvents?: readonly WorkAttemptEvent[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly _id: Id<"workRuns">;
readonly status: string;
readonly terminalClassification?: string;
readonly terminalSummary?: string;
}
export interface WorkArtifact {
readonly _id: string;
readonly metadataJson: string;
readonly title: string;
readonly uri?: string;
}
export interface WorkAttemptEvent {
readonly _id: string;
readonly message: string;
}
export interface GitConnection {
readonly id: string;
readonly provider: "github" | "gitea";
readonly serverUrl: string;
readonly username?: string;
}
export interface ProjectListItem {
readonly id: string;
readonly name: string;
}
export interface WorkspaceState {
readonly agent: {
readonly error?: Error;
readonly historyReady: boolean;
readonly messages: readonly ConversationMessage[];
readonly sendMessage: (
message: string,
options?: { readonly images?: readonly File[] }
) => Promise<void>;
readonly status:
| "connecting"
| "error"
| "idle"
| "streaming"
| "submitted";
};
readonly authorizeGithub: () => Promise<unknown>;
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly clearOperationError: () => void;
readonly connectGitea: (input: {
readonly token: string;
readonly username?: string;
}) => Promise<void>;
readonly connectLinkedGithub: () => Promise<void>;
readonly connectRepository: () => Promise<void>;
readonly error?: Error;
readonly gitConnections: readonly GitConnection[] | undefined;
readonly operationError?: Error;
readonly pending: boolean;
readonly projectGitConnection: GitConnection | null | undefined;
readonly projects: readonly ProjectListItem[] | undefined;
readonly repository: string;
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
readonly retryExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly saveDefinition: (
workId: Id<"works">,
payload: unknown
) => Promise<unknown>;
readonly saveDesign: (
workId: Id<"works">,
payload: unknown
) => Promise<unknown>;
readonly selectProject: (projectId: string) => void;
readonly selectedProject: ProjectListItem | null;
readonly setRepository: (value: string) => void;
readonly startExecution: (
workId: Id<"works">,
sliceId?: string
) => Promise<unknown>;
readonly works: readonly WorkRecord[] | undefined;
}
export type WorkspaceTimelineItem =
| { readonly kind: "message"; readonly message: ConversationMessage }
| { readonly kind: "work"; readonly notice: WorkNotice };
export interface ArtifactMetadata {
readonly changedFiles?: readonly string[];
}
export const parseArtifactMetadata = (
artifact: WorkArtifact
): ArtifactMetadata => {
if (!artifact.metadataJson) {
return {};
}
try {
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
} catch {
return {};
}
};
export const changedFilesFor = (artifact: WorkArtifact): readonly string[] =>
parseArtifactMetadata(artifact).changedFiles ?? [];

View File

@@ -7,7 +7,7 @@ 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"),
index("./routes/app/workspace/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -1,5 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function Dashboard() {
return <SliceOnePage />;
}

View File

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

View File

@@ -1,5 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function MobileLandingRedirect() {
return <SliceOnePage />;
}

View File

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

View File

@@ -0,0 +1,5 @@
import { ProjectWorkspacePage } from "@/components/workspace/project-workspace-page";
export default function ProjectWorkspaceRoute() {
return <ProjectWorkspacePage />;
}

View File

@@ -2,19 +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, "../.."),
export default defineConfig(({ mode }) => {
const envDir = path.resolve(import.meta.dirname, "../..");
const env = loadEnv(mode, envDir, "");
return {
envDir,
plugins: [tailwindcss(), reactRouter()],
ssr: {
noExternal: true,
},
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: env.CONVEX_SITE_URL,
},
},
},
};
});

4643
bun.lock

File diff suppressed because it is too large Load Diff

63
deploy/compose/Caddyfile Normal file
View File

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

View File

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

View File

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

17
deploy/systemd/Caddyfile Normal file
View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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."

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,16 +0,0 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -1,11 +0,0 @@
services:
zopu-agentos-runner:
build:
context: ../../..
dockerfile: deploy/zopu-runtime/runner/Dockerfile
environment:
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_ENVOY_VERSION: ${RIVET_ENVOY_VERSION}
RIVET_POOL: default
restart: unless-stopped

View File

@@ -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"

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,9 +0,0 @@
[Unit]
Description=Zopu Docker Cleanup (daily)
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -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

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Zopu Health Check (every 60s)
[Timer]
OnBootSec=30
OnUnitActiveSec=60
AccuracySec=5
[Install]
WantedBy=timers.target

View File

@@ -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

168
docs/DEPLOYMENT_PLAN.md Normal file
View File

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

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