88 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
-Puter
a53029cf7a fix: await Rivet envoy registration 2026-07-28 15:34:08 +05:30
-Puter
eede4c10ba fix: build runner native modules with Node 2026-07-28 15:20:03 +05:30
-Puter
35169672e1 fix: add runner native build toolchain 2026-07-28 15:12:57 +05:30
-Puter
359d9e2285 feat: add durable AgentOS slice execution 2026-07-28 14:50:45 +05:30
-Puter
05a3baaac3 fix: bundle all SSR deps to eliminate React instance duplication 2026-07-28 03:27:41 +05:30
-Puter
4cc40cb2e4 fix: broaden SSR noExternal to cover all React-dependent workspace packages 2026-07-28 03:25:27 +05:30
-Puter
814df02be9 fix: bundle @convex-dev/better-auth in SSR to prevent React instance duplication 2026-07-28 03:21:41 +05:30
-Puter
d7f6cbdcdc fix: pin react/react-dom to 19.2.8 to prevent SSR duplicate instance crash 2026-07-28 03:16:34 +05:30
-Puter
a8d946b6a9 Slice 4 control-plane repairs, lint/catalog cleanup, Flue adapter fixes
Convex control plane (Slices 1-4 prerequisites for Slice 5):
- Versioned Definition/Design persistence: revisions preserve history
  instead of deleting prior slices/approvals
- Fenced conversation turn queue: attempt-numbered leases prevent stale
  worker overwrites; expired turns reconciled by cron
- Fenced Run/Attempt claiming: only queued attempts from running Runs can
  be claimed; lease expiry checks on every finish/checkpoint
- Multi-slice progression: successful slice marks next slice ready instead
  of completing the whole Work; completed Runs blocked from retry
- Typed AttemptClassification in schema and resolver decisions table
- Durable resolver decisions for normal outcomes, cancellation, and
  lease-expiry recovery
- Persistent artifacts and delivery metadata with provenance, source
  revision, verification status, and controlled delivery transitions
- High-impact open questions block definition approval
- Question submission gated to defining/awaiting-approval states only
- Delivery status updates validate JSON metadata before persisting
- Planner failures recorded as durable blocked events
- Approval identity uses canonical tokenIdentifier

Primitives:
- decodeDefinition separates decode from validate
- Design validation enforces 1-4 slices with unique IDs and valid deps
- Artifact and delivery draft schemas with verified-revision binding
- Delivery status transition table

Package management:
- Consolidated shared deps into single catalog (hono, valibot, streamdown,
  @types/react, @types/node, @tailwindcss/*, react-native)
- Removed cross-version Hono boundary: flue() exported as Fetchable
- Deleted bun.lock and regenerated from clean catalog resolution

Lint/format:
- Removed .eslintignore; oxc uses native ignorePatterns in oxlint.config.ts
- Deleted redundant packages/backend/.oxlintrc.json
- Added repos/** and scripts/** to ignore patterns
- Convex-specific rule overrides for ES2022 target constraints
- Fixed all oxc errors in fluePersistence.ts and agents/src/db.ts
- Fixed all formatting across changed files
2026-07-28 02:53:05 +05:30
0e32c35515 Slice 4 hardening: bounded resolver, terminal states, reconciliation (#21)
Fixes review findings before Slice 5. Resolver: resolveOutcome + WORK_STATUS_FOR_OUTCOME + lifecycle fixes. Execution: bounded finishAttempt retry, terminal Work status preservation, slice validation, bounded reconciliation with 30s cron. Planning: revision guards, durable submitQuestion, design-approval invalidation. Primitives 69 (+4), backend 22 (+10) tests; root typecheck/build/Ultracite pass.
2026-07-27 18:39:09 +00:00
-Puter
005b26fa32 Slices 2-4: Work becomes executable
Domain primitives: versioned WorkDefinition/DesignPacket with risk, questions,
and approval gates; lifecycle transition table with invalidation rules;
Resolver (Run/Attempt/outcomes/CodingKitV0); provider-neutral HarnessRuntime
with normalized events and a deterministic FakeHarnessLive that always reaches
a terminal classification and never claims implementation.

Convex durable model: workDefinitions, workQuestions, workApprovals,
designPackets, workSlices, workRuns, workAttempts, workAttemptEvents tables;
broadened works.status union; generalized workEvents with optional signalId,
referenceId, and payloadJson. Authenticated commands for definition request/
save/revise/approve, question answer/withdraw, design save/revise/approve, and
simulated execution start/cancel/retry with leased attempts, checkpointed
events, and expired-lease reconciliation.

Private work-planner FLUE agent with proposal-only tools (definition, design,
question). Convex validates and stores every proposal; FLUE never approves or
advances Work.

Expanded Work card with Outcome, Design, and Build sections wired to the new
mutations. Slice 1 conversation and provenance preserved.
2026-07-27 22:52:14 +05:30
cb7484912c Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20) 2026-07-27 16:03:36 +00:00
-Puter
cc47007fa9 Slice 1 polish: archive dormant code, merge work-os into primitives, add futures
- Archive dormant apps (daemon, desktop, native, tui) and superseded
  packages/server into repos/ for reference
- Archive 21 dead web components (chat page shell, work-os cluster, strays)
  into repos/web/; keep reused message-rendering primitives in components/chat
- Merge @code/work-os into @code/primitives; work.ts absorbed via ./work
  subpath and barrel export; update all four importers
- Add docs/futures.md tracking audio/video (blocked at Flue + provider),
  web layout cleanup, and message rendering quality
- Repoint dev:zopu script to @code/agents (the live Flue server)
- Note repos/ reference-code convention in AGENTS.md
2026-07-27 16:34:17 +05:30
-Puter
302fd159df docs: Slice 1 handoff notes 2026-07-27 15:22:16 +05:30
cfdb2efc70 Slice 1: Conversation to Proposed Work with MiMo V2.5 (#19) 2026-07-27 09:32:29 +00:00
-Puter
6da82f0ed8 merge: resolve conflicts with origin/master (routes, Caddy ports, scripts) 2026-07-27 15:01:38 +05:30
-Puter
789d26bb8d docs: deployment notes for local Mac dev and Cheaptricks staging 2026-07-27 12:59:04 +05:30
-Puter
4605dd7c92 fix: allow Caddy domain host in Vite dev server 2026-07-27 12:38:09 +05:30
-Puter
d8383a788e feat: Slice 1 polish - MiMo V2.5 model, visible reasoning, image attachments, mobile keyboard fix
- Switch conversation agent to xiaomi/mimo-v2.5 (multimodal: text + image)
- Render native reasoning parts as live 'Thinking trace' (streaming open,
  collapsed after completion); inline <think> extraction for streaming models
- Image attachments: picker (up to 4, 10MB each), base64 to Flue
  AgentPromptImage, authenticated blob-URL replay for historical images
- Mobile keyboard viewport fix: visual-viewport hook, fixed shell,
  interactive-widget=resizes-content, header pinned, composer follows keyboard
- Conversation to Signal to proposed Work: Convex persistence, Effect
  validation in @code/work-os, Work cards with exact source provenance
- Streamdown markdown + Mermaid chart rendering in chat messages
- Flue tool turns hidden, reasoning-containing turns remain visible
- Frontend regression tests: keyboard viewport, responsive shell,
  attachment overflow, authenticated images, reasoning traces, transforms
- .env.example updated to xiaomi/mimo-v2.5 config
2026-07-27 12:22:54 +05:30
sai karthik
58ff7942bd feat: add project connection to chat 2026-07-26 21:00:08 +05:30
sai karthik
39b27a229f feat: deploy zopu single-node production 2026-07-26 11:39:21 +05:30
-Puter
3bfa9ac65e deploy(caddy): reverse-proxy zopu chat + API behind zopu.cheaptricks.puter.wtf 2026-07-26 01:41:50 +05:30
sai karthik
2a0487aa6e feat: integrate mobile work chat and Gitea delivery 2026-07-26 00:50:11 +05:30
sai karthik
48200a11df Integrate mobile chat workspace and OpenRouter agent flow 2026-07-25 17:49:11 +05:30
-Puter
d4d5620c18 Merge origin/master into zopu chat setup 2026-07-25 16:12:43 +05:30
-Puter
d4745591a9 feat(zopu): chat server, standalone chat route, and CORS 2026-07-25 16:12:04 +05:30
a17aa5c283 Zopu dev bootstrap: git runtimes, Codex agent-os, dev agent
Merge t3code/explore-primitives-package onto master.
2026-07-24 21:50:45 +00:00
-Puter
19f771ea71 fix(backend): explicit types on linked-signals toSorted comparator
Resolves implicit-any type errors blocking the Convex dev server from
starting on master after the dogfood merge.
2026-07-25 02:40:48 +05:30
444 changed files with 56965 additions and 26244 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

@@ -6,10 +6,8 @@ SITE_URL=http://localhost:5173
NATIVE_APP_URL=code://
# Browser and native public endpoints
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://example.convex.cloud
VITE_CONVEX_SITE_URL=https://example.convex.site
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
VITE_FLUE_URL=http://localhost:3583
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
@@ -19,20 +17,28 @@ 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://127.0.0.1:3585
# Agent model provider
AGENT_MODEL_PROVIDER=cheaptricks
AGENT_MODEL_NAME=glm-5.2
AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.example.com/v1
AGENT_MODEL_API_KEY=replace-with-provider-api-key
AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072
# Self-hosted git (Gitea) — canonical repository host and API token
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
# Zopu lean server (standalone chat + work actor)
VITE_ZOPU_SERVER_URL=http://localhost:3590

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

View File

@@ -1 +1 @@
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/`, and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 12 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/` (archived reference code — dormant apps, superseded web components, and prior implementations kept for reference), and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 12 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.

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

@@ -15,8 +15,6 @@
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
"@react-router/fs-routes": "^8.1.0",
"@react-router/node": "^8.1.0",
"@react-router/serve": "^8.1.0",
@@ -24,19 +22,20 @@
"isbot": "^5.1.44",
"lucide-react": "catalog:",
"next-themes": "catalog:",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "^8.1.0",
"sonner": "catalog:",
"streamdown": "2.5.0"
"streamdown": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@react-router/dev": "^8.1.0",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^22.13.14",
"@types/react": "^19.2.17",
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vercel/react-router": "1.3.1",
"react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",

View File

@@ -1,7 +1,8 @@
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";
export default {
// Desktop addons package static web assets; SSR output cannot be bundled
ssr: false,
appDirectory: "src",
presets: [vercelPreset()],
ssr: true,
} satisfies Config;

View File

@@ -0,0 +1,162 @@
import { useConvexAccessToken } from "@code/auth/web";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@code/ui/components/attachment";
import { ImageIcon, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PendingChatImage } from "@/lib/chat/attachments";
import type { ConversationPart } from "@/lib/chat/types";
type FilePart = Extract<ConversationPart, { type: "file" }>;
const isDirectlyRenderableUrl = (url: string): boolean =>
url.startsWith("blob:") || url.startsWith("data:");
const useAuthenticatedImageSource = (url?: string): string | undefined => {
const resolveAccessToken = useConvexAccessToken();
const [loaded, setLoaded] = useState<{
readonly source: string;
readonly url: string;
}>();
useEffect(() => {
if (!url || isDirectlyRenderableUrl(url)) {
return;
}
let active = true;
let objectUrl: string | undefined;
const load = async (): Promise<void> => {
try {
const accessToken = await resolveAccessToken();
const response = await fetch(url, {
headers: accessToken
? { authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
return;
}
objectUrl = URL.createObjectURL(await response.blob());
if (active) {
setLoaded({ source: objectUrl, url });
}
} catch {
// The attachment remains represented by its filename and media type.
}
};
void load();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [resolveAccessToken, url]);
if (url && isDirectlyRenderableUrl(url)) {
return url;
}
return loaded && loaded.url === url ? loaded.source : undefined;
};
const ImagePreview = ({
alt,
mediaType,
url,
}: {
readonly alt: string;
readonly mediaType: string;
readonly url?: string;
}) => {
const source = useAuthenticatedImageSource(url);
return (
<AttachmentMedia
variant={mediaType.startsWith("image/") ? "image" : "icon"}
>
{source && mediaType.startsWith("image/") ? (
<img alt={alt} className="size-full object-cover" src={source} />
) : (
<ImageIcon className="size-4" />
)}
</AttachmentMedia>
);
};
export const PendingChatAttachments = ({
images,
onRemove,
}: {
readonly images: readonly PendingChatImage[];
readonly onRemove: (id: string) => void;
}) => (
<AttachmentGroup className="max-w-full gap-2 pb-2">
{images.map((image) => (
<Attachment
className="w-24 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={image.id}
orientation="vertical"
size="sm"
>
<ImagePreview
alt={image.file.name}
mediaType={image.file.type}
url={image.previewUrl}
/>
<AttachmentContent>
<AttachmentTitle>{image.file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${image.file.name}`}
className="bg-white/90 hover:bg-white"
onClick={() => onRemove(image.id)}
>
<X className="size-3.5" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
);
export const MessageAttachments = ({
parts,
}: {
readonly parts: readonly FilePart[];
}) => {
if (parts.length === 0) {
return null;
}
return (
<AttachmentGroup className="mb-2 max-w-full gap-2">
{parts.map((part, index) => {
const title = part.filename ?? `Image ${index + 1}`;
return (
<Attachment
className="min-w-0 max-w-48 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={part.id ?? `${part.mediaType}:${index}`}
size="sm"
>
<ImagePreview
alt={title}
mediaType={part.mediaType}
url={part.url}
/>
<AttachmentContent>
<AttachmentTitle>{title}</AttachmentTitle>
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
</AttachmentContent>
</Attachment>
);
})}
</AttachmentGroup>
);
};

View File

@@ -7,26 +7,60 @@ import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types";
import {
getMessageText,
getReasoningText,
isReasoningStreaming,
isMessageStreaming,
} from "@/lib/chat/transforms";
import type {
AssistantResponseState,
ChatMessageProps,
} from "@/lib/chat/types";
import { AssistantIdentity } from "./assistant-identity";
import { ChatToolCall } from "./chat-tool-call";
import { MessageAttachments } from "./chat-attachments";
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
if (message.parts.length === 0 && !isStreaming) {
const ReasoningTrace = ({
isStreaming,
text,
}: {
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (!text) {
return null;
}
return (
<details
className="mb-3 border-l-2 border-[#b9b5aa] pl-3 text-[#5f5d55]"
open={isStreaming ? true : undefined}
>
<summary className="cursor-pointer select-none text-xs font-medium text-[#69675e]">
{isStreaming ? "Thinking live" : "Thinking trace"}
</summary>
<MessageResponse
className="chat-reasoning mt-2 min-w-0 text-xs leading-5"
isAnimating={isStreaming}
>
{text}
</MessageResponse>
</details>
);
};
let content: ReactNode = text;
if (!isUser) {
content = text ? (
const AssistantText = ({
hasReasoning,
isStreaming,
text,
}: {
readonly hasReasoning: boolean;
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (text) {
return (
<MessageResponse
caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0"
@@ -34,33 +68,71 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
>
{text}
</MessageResponse>
) : (
);
}
if (hasReasoning) {
return null;
}
return (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
<span />
<span />
</div>
);
};
const assistantState = (
isStreaming: boolean,
reasoningStreaming: boolean,
text: string
): AssistantResponseState | undefined => {
if (reasoningStreaming && !text) {
return "thinking";
}
return isStreaming ? "writing" : undefined;
};
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
const reasoning = getReasoningText(message);
const reasoningStreaming = isReasoningStreaming(message);
const fileParts = message.parts.filter((part) => part.type === "file");
if (message.parts.length === 0 && !isStreaming) {
return null;
}
const sender = isUser ? "user" : "assistant";
const content = isUser ? (
text
) : (
<AssistantText
hasReasoning={Boolean(reasoning)}
isStreaming={isStreaming}
text={text}
/>
);
const identityState = assistantState(isStreaming, reasoningStreaming, text);
return (
<Message className="max-w-full gap-0" from={message.role}>
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
{isUser ? null : <AssistantIdentity state={identityState} />}
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
<MessageAttachments parts={fileParts} />
{isUser ? null : (
<ReasoningTrace
isStreaming={reasoningStreaming}
text={reasoning}
/>
)}
{content}
</MobileChatBubble>

View File

@@ -1,60 +0,0 @@
import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
} from "@code/ui/components/ai-elements/tool";
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
import { Search, SquareTerminal } from "lucide-react";
import type { ChatToolCallProps } from "@/lib/chat/types";
export const ChatToolCall = ({ part }: ChatToolCallProps) => {
let detail =
typeof part.input === "string"
? part.input
: JSON.stringify(part.input, null, 2);
let status = "running";
let tone: "error" | "neutral" | "success" = "neutral";
if (part.state === "output-available") {
detail =
typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2);
status = "done";
tone = "success";
} else if (part.state === "output-error") {
detail = part.errorText;
status = "failed";
tone = "error";
}
const isSearch = part.toolName.toLowerCase().includes("search");
const output = part.state === "output-available" ? part.output : undefined;
const errorText = part.state === "output-error" ? part.errorText : undefined;
return (
<Tool className="mb-0 w-full border-0" defaultOpen>
<ToolContent className="space-y-0 p-0">
<MobileChatToolCall
detail={detail}
icon={
isSearch ? (
<Search className="size-3" strokeWidth={2.2} />
) : (
<SquareTerminal className="size-3" strokeWidth={2.2} />
)
}
status={status}
tone={tone}
toolName={part.toolName}
/>
<div className="sr-only">
<ToolInput input={part.input} />
<ToolOutput errorText={errorText} output={output} />
</div>
</ToolContent>
</Tool>
);
};

View File

@@ -1,102 +0,0 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
export type MobileFlowScreen =
| "assistant-chat"
| "home"
| "work-list"
| "work-unit-detail";
interface MobileFlowPageProps {
screen: MobileFlowScreen;
}
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate();
const { workUnitId } = useParams();
const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId,
});
const workspace = useMobileWorkspace({
onCreateIssue: (title, body) =>
projectWorkspace.raiseIssue({ body, title }),
onSend: projectWorkspace.sendMessage,
});
const workPath = "/work";
const chatPath = "/chat";
const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) {
navigate("/dashboard");
return;
}
if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true);
return;
}
navigate(`/work/${targetId}`);
};
const screenProps = {
composerValue: workspace.composerValue,
createIssueBody: workspace.issueBody,
createIssueTitle: workspace.issueTitle,
data: projectWorkspace.data,
onBack: () => navigate(workPath),
onComposerChange: workspace.handleComposerChange,
onComposerSubmit: workspace.handleComposerSubmit,
onCreateBodyChange: workspace.setIssueBody,
onCreateIssue: workspace.handleCreateIssueSubmit,
onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId
? () =>
void projectWorkspace.raiseIssueFromSignal(
projectWorkspace.data.latestSignal?.id ?? ""
)
: undefined,
onCreateTitleChange: workspace.setIssueTitle,
onManageProjects: () => navigate("/dashboard"),
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onProjectSelect: (projectId: string) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
),
onReviewUnit: (reviewUrl: string) => {
window.open(reviewUrl, "_blank", "noopener,noreferrer");
},
onStartUnit: (selectedIssueId: string) => {
void projectWorkspace.startWorkUnit(
selectedIssueId as Id<"projectIssues">
);
},
onViewWork: () => navigate(workPath),
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
};
if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />;
}
if (screen === "home") {
return <MobileHomeScreen {...screenProps} />;
}
if (screen === "work-unit-detail") {
return <MobileWorkUnitDetailScreen {...screenProps} />;
}
if (workspace.expanded) {
return <MobileExpandedWorkScreen {...screenProps} />;
}
return <MobileWorkListScreen {...screenProps} />;
};

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

@@ -1,60 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { useChatAgent } from "./use-chat-agent";
const mocks = vi.hoisted(() => ({
agent: {
error: undefined,
historyReady: true,
messages: [],
sendMessage: vi.fn(() => Promise.resolve()),
status: "idle" as const,
},
organization: {} as { error?: Error; organizationId?: string },
useFlueAgent: vi.fn(),
}));
vi.mock("@flue/react", () => ({
useFlueAgent: mocks.useFlueAgent,
}));
vi.mock("@/hooks/use-personal-organization", () => ({
usePersonalOrganization: () => mocks.organization,
}));
describe("useChatAgent", () => {
beforeEach(() => {
mocks.organization = {};
mocks.agent.sendMessage.mockClear();
mocks.useFlueAgent.mockReset();
mocks.useFlueAgent.mockReturnValue(mocks.agent);
});
test("does not use the agent before organization bootstrap completes", async () => {
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: undefined,
live: "sse",
name: "zopu",
});
expect(chat.status).toBe("connecting");
await expect(chat.sendMessage("too early")).rejects.toThrow(
"Personal organization is still being prepared"
);
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
});
test("uses the ensured organization id for the agent session", async () => {
mocks.organization = { organizationId: "org-a" };
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: "org-a",
live: "sse",
name: "zopu",
});
await chat.sendMessage("ready");
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
});
});

View File

@@ -1,41 +1,111 @@
import { useFlueAgent } from "@flue/react";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types";
import { projectConversation } from "@/lib/chat/conversation";
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
const requestId = (): string =>
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const uploadImage = async (
file: File,
generateUploadUrl: () => Promise<string>
) => {
const response = await fetch(await generateUploadUrl(), {
body: file,
headers: { "content-type": file.type },
method: "POST",
});
if (!response.ok) {
throw new Error(`Image upload failed (${response.status})`);
}
const payload: unknown = await response.json();
if (
typeof payload !== "object" ||
payload === null ||
!("storageId" in payload) ||
typeof payload.storageId !== "string"
) {
throw new Error("Image upload returned an invalid response");
}
return {
filename: file.name || undefined,
mimeType: file.type,
storageId: payload.storageId as Id<"_storage">,
};
};
export const useOrganizationChatAgent = (
organization: PersonalOrganizationState
): ChatAgentState => {
const agent = useFlueAgent({
...CHAT_AGENT,
id: organization.organizationId,
});
const { organizationId } = organization;
const rows = useQuery(
api.conversationMessages.listForCurrentOrganization,
organizationId ? { organizationId } : "skip"
);
const send = useMutation(api.conversationMessages.send);
const generateUploadUrl = useMutation(
api.conversationMessages.generateUploadUrl
);
const [sendError, setSendError] = useState<Error>();
const sendMessage = async (message: string): Promise<void> => {
if (!organization.organizationId) {
const projected = projectConversation(rows ?? []);
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) {
status = "error";
}
const sendMessage = async (
message: string,
options?: { readonly images?: readonly File[] }
): Promise<void> => {
if (!organizationId) {
throw (
organization.error ??
new Error("Personal organization is still being prepared")
);
}
await agent.sendMessage(message);
setSendError(undefined);
try {
const images = await Promise.all(
(options?.images ?? []).map((file) =>
uploadImage(file, generateUploadUrl)
)
);
await send({
clientRequestId: requestId(),
images,
organizationId,
rawText: message,
});
} catch (error) {
const normalized =
error instanceof Error ? error : new Error(String(error));
setSendError(normalized);
throw normalized;
}
};
let { status } = agent;
if (!organization.organizationId) {
status = organization.error ? "error" : "connecting";
}
return {
error: organization.error ?? agent.error,
historyReady: agent.historyReady,
messages: agent.messages,
error:
organization.error ??
sendError ??
(projected.failedError ? new Error(projected.failedError) : undefined),
historyReady: rows !== undefined,
messages: projected.messages,
sendMessage,
status,
};
};
export const useChatAgent = (): ChatAgentState =>
useOrganizationChatAgent(usePersonalOrganization());

View File

@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
import type { PendingChatImage } from "@/lib/chat/attachments";
const generateImageId = (): string =>
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
export const useChatImages = () => {
const [images, setImages] = useState<PendingChatImage[]>([]);
const [error, setError] = useState<string>();
const previewUrls = useRef(new Set<string>());
useEffect(
() => () => {
for (const previewUrl of previewUrls.current) {
URL.revokeObjectURL(previewUrl);
}
},
[]
);
const addFiles = (files: FileList | null) => {
if (!files) {
return;
}
setError(undefined);
setImages((current) => {
const remaining = MAX_CHAT_IMAGES - current.length;
if (remaining <= 0) {
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
return current;
}
const next = [...current];
for (const file of [...files].slice(0, remaining)) {
const validation = validateChatImage(file);
if (!validation.accepted) {
setError(validation.message);
continue;
}
const previewUrl = URL.createObjectURL(file);
previewUrls.current.add(previewUrl);
next.push({
file,
id: generateImageId(),
previewUrl,
});
}
if (files.length > remaining) {
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
}
return next;
});
};
const remove = (id: string) => {
setImages((current) => {
const removed = current.find((image) => image.id === id);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
previewUrls.current.delete(removed.previewUrl);
}
return current.filter((image) => image.id !== id);
});
setError(undefined);
};
const clear = () => {
for (const previewUrl of previewUrls.current) {
URL.revokeObjectURL(previewUrl);
}
previewUrls.current.clear();
setImages([]);
setError(undefined);
};
return { addFiles, clear, error, handleRemove: remove, images } 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

@@ -1,91 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type {
MobileAssistantMessageView,
MobileAssistantView,
} from "@code/ui/components/mobile-product";
import { useQuery } from "convex/react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import { STATUS_COPY } from "@/lib/chat/constants";
import { getMessageText } from "@/lib/chat/transforms";
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
): MobileAssistantMessageView[] =>
messages.flatMap((message) => {
if (message.role !== "assistant" && message.role !== "user") {
return [];
}
const text = getMessageText(message);
return text
? [
{
id: message.id,
role: message.role,
text,
},
]
: [];
});
const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"]
): MobileAssistantView["statusTone"] => {
if (status === "error") {
return "red";
}
return status === "connecting" ? "amber" : "green";
};
interface UseMobileProjectWorkspaceOptions {
readonly selectedWorkUnitId?: string;
}
export const useMobileProjectWorkspace = ({
selectedWorkUnitId,
}: UseMobileProjectWorkspaceOptions) => {
const organization = usePersonalOrganization();
const projectWorkspace = useProjectWorkspace();
const chatAgent = useOrganizationChatAgent(organization);
const signals = useQuery(
api.signals.list,
organization.organizationId
? { organizationId: organization.organizationId }
: "skip"
);
const assistant: MobileAssistantView = {
isBusy:
chatAgent.status === "connecting" ||
chatAgent.status === "streaming" ||
chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages),
statusLabel: STATUS_COPY[chatAgent.status],
statusTone: getStatusTone(chatAgent.status),
};
return {
data: buildMobileWorkspaceView({
artifacts: projectWorkspace.artifacts,
assistant,
events: projectWorkspace.events,
issues: projectWorkspace.issues,
organizationLabel: organization.organizationName,
projects: projectWorkspace.projects,
selectedProject: projectWorkspace.selectedProject,
selectedWorkUnitId,
signals,
}),
error: organization.error ?? chatAgent.error,
organization,
projectWorkspace,
raiseIssue: projectWorkspace.raiseIssue,
raiseIssueFromSignal: (signalId: string) =>
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
sendMessage: chatAgent.sendMessage,
startWorkUnit: projectWorkspace.startWorkUnit,
} as const;
};

View File

@@ -1,82 +0,0 @@
import type { FormEvent } from "react";
import { useState } from "react";
interface UseMobileWorkspaceOptions {
readonly initialExpanded?: boolean;
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
readonly onSend: (message: string) => Promise<void>;
}
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : "Message could not be sent";
export const useMobileWorkspace = ({
initialExpanded = false,
onCreateIssue,
onSend,
}: UseMobileWorkspaceOptions) => {
const [composerValue, setComposerValue] = useState("");
const [issueBody, setIssueBody] = useState("");
const [issueTitle, setIssueTitle] = useState("");
const [expanded, setExpanded] = useState(initialExpanded);
const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => {
setComposerValue(value);
setStatusMessage(undefined);
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const message = composerValue.trim();
if (!message) {
return;
}
setStatusMessage("Sending to Zopu…");
const sendMessage = async () => {
try {
await onSend(message);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void sendMessage();
};
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const title = issueTitle.trim();
const body = issueBody.trim();
if (!title || !body) {
return;
}
setStatusMessage("Creating project work…");
const createIssue = async () => {
try {
await onCreateIssue(title, body);
setIssueTitle("");
setIssueBody("");
setStatusMessage("Project work created");
} catch (error) {
setStatusMessage(errorMessage(error));
}
};
void createIssue();
};
return {
composerValue,
expanded,
handleComposerChange,
handleComposerSubmit,
handleCreateIssueSubmit,
issueBody,
issueTitle,
setExpanded,
setIssueBody,
setIssueTitle,
statusMessage,
};
};

View File

@@ -1,7 +1,7 @@
import { useWebAuth } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useConvexAuth, useMutation } from "convex/react";
import { useEffect, useState } from "react";
export interface PersonalOrganizationState {
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
/** Ensure the authenticated user has its personal tenancy boundary. */
export const usePersonalOrganization = (): PersonalOrganizationState => {
const auth = useWebAuth();
const { isAuthenticated, isRefreshing } = useConvexAuth();
const convexReady = isAuthenticated && !isRefreshing;
const userId = auth.status === "authenticated" ? auth.user.id : null;
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
}>();
useEffect(() => {
if (!userId) {
if (!convexReady || !userId) {
return;
}
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
return () => {
active = false;
};
}, [ensurePersonalOrganization, userId]);
}, [convexReady, ensurePersonalOrganization, userId]);
if (!userId || state?.userId !== userId) {
if (!convexReady || !userId || state?.userId !== userId) {
return {};
}

View File

@@ -1,194 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueClient } from "@flue/react";
import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "@/lib/projects/project-evidence";
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => {
const flueClient = useFlueClient();
const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState("");
const [selectedIssueId, setSelectedIssueId] =
useState<Id<"projectIssues"> | null>(null);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const importPublicGit = useAction(api.projects.importPublicGit);
const createIssue = useMutation(api.projectIssues.create);
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
const beginIssue = useMutation(api.projectIssues.begin);
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
const activeProjectId =
selectedProjectId ??
(projects?.[0]?.id as unknown as Id<"projects">) ??
null;
const artifacts = useQuery(
api.projectArtifacts.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const issues = useQuery(
api.projectIssues.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const events = useQuery(
api.projectIssues.events,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const pullRequest = events
?.map((event) => event.data)
.find(
(
data
): data is {
pullRequest: {
baseBranch: string;
branch: string;
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof data === "object" &&
data !== null &&
"pullRequest" in data &&
typeof data.pullRequest === "object" &&
data.pullRequest !== null
)?.pullRequest;
const connectRepository = async () => {
setPendingAction("connect");
setError(null);
try {
const outcome = await importPublicGit({ repositoryUrl: repository });
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
setRepository("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const raiseIssue = async (input?: {
readonly body?: string;
readonly title?: string;
}) => {
if (!activeProjectId) {
return;
}
const nextTitle = input?.title ?? issueTitle;
const nextBody = input?.body ?? issueBody;
setPendingAction("issue");
setError(null);
try {
const issueId = await createIssue({
body: nextBody,
projectId: activeProjectId,
title: nextTitle,
});
setSelectedIssueId(issueId);
setIssueTitle("");
setIssueBody("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
setPendingAction(`signal:${signalId}`);
setError(null);
try {
const outcome = await createIssueFromSignal({ signalId });
setSelectedIssueId(outcome.issueId);
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const startIssue = async (
issueId: Id<"projectIssues">,
issueNumber: number,
title: string
) => {
const actionKey = `issue:${issueId}`;
setPendingAction(actionKey);
setError(null);
try {
await beginIssue({ issueId });
await flueClient.agents.send("project-manager", String(issueId), {
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
});
} catch (caughtError) {
const message = errorMessage(caughtError);
await markDispatchFailed({ error: message, issueId });
setError(message);
} finally {
setPendingAction(null);
}
};
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
const issue = issues?.find((candidate) => candidate._id === issueId);
if (!issue) {
setError("That work unit is no longer available");
return;
}
await startIssue(issue._id, issue.number, issue.title);
};
const selectedProject =
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null;
const selectedIssue =
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
const projectLoop = buildProjectLoopView({
artifacts,
issue: selectedIssue,
source: selectedProject?.sources[0],
});
const issueSummary = summarizeProjectIssues(issues);
return {
artifacts,
connectRepository,
error,
events,
issueBody,
issueSummary,
issueTitle,
issues,
pendingAction,
projectLoop,
projects,
pullRequest,
raiseIssue,
raiseIssueFromSignal,
repository,
selectedIssue,
selectedIssueId,
selectedProject,
selectedProjectId: activeProjectId,
setIssueBody,
setIssueTitle,
setRepository,
setSelectedIssueId,
setSelectedProjectId,
startIssue,
startWorkUnit,
} as const;
};

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

@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-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",
transform: undefined,
});
});
test("tracks a panned visual viewport without scrolling the document", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 44 })).toEqual({
height: "500px",
transform: "translateY(44px)",
});
});
test("uses a dynamic viewport fallback before browser measurement", () => {
expect(visualViewportStyle()).toEqual({
height: "100dvh",
transform: undefined,
});
});
});

View File

@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
export interface VisualViewportFrame {
readonly height: number;
readonly offsetTop: number;
}
const readVisualViewport = (): VisualViewportFrame => ({
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
offsetTop: Math.round(window.visualViewport?.offsetTop ?? 0),
});
export const visualViewportStyle = (
frame?: VisualViewportFrame
): CSSProperties => ({
height: frame ? `${frame.height}px` : "100dvh",
transform: frame?.offsetTop ? `translateY(${frame.offsetTop}px)` : undefined,
});
export const useVisualViewportStyle = (): CSSProperties => {
const [frame, setFrame] = useState<VisualViewportFrame>();
useEffect(() => {
const root = document.documentElement;
const viewport = window.visualViewport;
let animationFrame = 0;
const update = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
setFrame(readVisualViewport());
});
};
root.classList.add("workspace-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("workspace-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
};
}, []);
return visualViewportStyle(frame);
};

View File

@@ -6,6 +6,13 @@ body {
min-height: 100%;
}
html.workspace-viewport-lock,
html.workspace-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
.ai-conversation-mobile > div {
scrollbar-gutter: auto !important;
}
@@ -68,6 +75,19 @@ body {
min-height: 1.75rem;
}
.route-progress-bar {
animation: route-progress 900ms ease-in-out infinite;
}
@keyframes route-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(400%);
}
}
@keyframes response-dot {
0%,
60%,
@@ -96,6 +116,45 @@ body {
color: var(--foreground);
}
/*
* 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;
}
.chat-markdown > :first-child {
margin-top: 0;
}
@@ -197,6 +256,16 @@ 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

@@ -0,0 +1,63 @@
import { redirect } from "react-router";
interface AuthLoaderData {
readonly token: string | null;
}
export const loadAuthToken = async (
request: Request
): Promise<AuthLoaderData> => {
const cookie = request.headers.get("cookie");
if (!cookie) {
return { token: null };
}
const tokenUrl = new URL(
"/api/auth/convex/token",
new URL(request.url).origin
);
const headers = new Headers({ cookie });
const response = await fetch(tokenUrl, { headers });
if (response.status === 401) {
return { token: null };
}
if (!response.ok) {
throw new Response("Authentication service unavailable", { status: 503 });
}
const payload: unknown = await response.json();
if (
typeof payload !== "object" ||
payload === null ||
!("token" in payload) ||
typeof payload.token !== "string"
) {
throw new Response("Authentication service returned an invalid response", {
status: 502,
});
}
return { token: payload.token };
};
export const requireAuthToken = async (
request: Request
): Promise<AuthLoaderData> => {
const auth = await loadAuthToken(request);
if (!auth.token) {
const requestUrl = new URL(request.url);
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
return auth;
};
export const redirectAuthenticated = async (
request: Request
): Promise<AuthLoaderData> => {
const auth = await loadAuthToken(request);
if (auth.token) {
const requestUrl = new URL(request.url);
const returnTo = requestUrl.searchParams.get("returnTo");
throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
}
return auth;
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
describe("chat image attachments", () => {
test("accepts supported image payloads within the upload limit", () => {
expect(validateChatImage({ size: 1024, type: "image/png" })).toEqual({
accepted: true,
});
});
test("rejects non-images and oversized images before transport", () => {
expect(validateChatImage({ size: 1024, type: "text/plain" }).accepted).toBe(
false
);
expect(
validateChatImage({
size: MAX_CHAT_IMAGE_BYTES + 1,
type: "image/jpeg",
}).accepted
).toBe(false);
});
});

View File

@@ -0,0 +1,28 @@
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export interface PendingChatImage {
readonly file: File;
readonly id: string;
readonly previewUrl: string;
}
export interface ImageValidationResult {
readonly accepted: boolean;
readonly message?: string;
}
export const validateChatImage = (
file: Pick<File, "size" | "type">
): ImageValidationResult => {
if (!file.type.startsWith("image/")) {
return { accepted: false, message: "Only image files can be attached" };
}
if (file.size > MAX_CHAT_IMAGE_BYTES) {
return {
accepted: false,
message: "Images must be 10 MB or smaller",
};
}
return { accepted: true };
};

View File

@@ -1,22 +1,12 @@
import type { AgentStatus } from "@flue/react";
import type { AgentStatus } from "./types";
/**
* The global Zopu agent. The instance `id` is the current organization id
* (resolved at runtime by the chat hook), not the legacy `main` shared
* instance. Organization scoping is the hard tenancy boundary.
*/
export const CHAT_AGENT = {
live: "sse",
name: "zopu",
} as const;
export const MODEL_LABEL = "GLM-5.2";
export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"],
["Make a plan", "Turn an idea into clear next steps"],
["Explain simply", "Break down something complex"],
] as const;
export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
export const MODEL_LABEL = "GPT-OSS 20B Free";
export const STATUS_COPY: Record<AgentStatus, string> = {
connecting: "Connecting",

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "vitest";
import type { ConversationRow } from "./conversation";
import { projectConversation } from "./conversation";
const row = (
overrides: Partial<ConversationRow> &
Pick<ConversationRow, "messageId" | "role" | "status">
): ConversationRow => ({
attachments: [],
error: null,
rawText: "",
...overrides,
});
describe("projectConversation", () => {
test("keeps queued assistant rows as status instead of duplicate messages", () => {
const state = projectConversation([
row({
messageId: "user-1",
rawText: "Build it",
role: "user",
status: "dispatching",
}),
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
]);
expect(state.pending).toBe(true);
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({
messageId: "assistant-1",
rawText: "Captured the request.",
role: "assistant",
status: "completed",
}),
]);
expect(state.messages[0]?.parts).toEqual([
{ state: "done", text: "Captured the request.", type: "text" },
]);
});
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

@@ -0,0 +1,83 @@
import type { ConversationMessage } from "./types";
export interface ConversationRow {
readonly attachments: readonly {
readonly filename: string | null;
readonly id: string;
readonly mediaType: string;
readonly url: string | null;
}[];
readonly error: string | null;
readonly messageId: string;
readonly rawText: string;
readonly role: "assistant" | "user";
readonly status:
| "aborted"
| "completed"
| "dispatching"
| "failed"
| "queued"
| "running";
}
const latestAssistant = (
rows: readonly ConversationRow[]
): ConversationRow | undefined =>
rows.findLast((row) => row.role === "assistant");
const latestTerminalError = (row: ConversationRow | undefined) =>
row?.status === "failed"
? (row.error ?? "Conversation turn failed")
: undefined;
export const projectConversation = (rows: readonly ConversationRow[]) => {
const activeAssistant = latestAssistant(rows);
const streaming = rows.some(
(row) =>
row.role === "assistant" &&
row.status === "running" &&
row.rawText.length > 0
);
return {
failedError: latestTerminalError(activeAssistant),
messages: rows
.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: [
...row.attachments.map((attachment) => ({
filename: attachment.filename ?? undefined,
id: attachment.id,
mediaType: attachment.mediaType,
type: "file" as const,
url: attachment.url ?? undefined,
})),
...(row.rawText
? [
{
state:
row.status === "running"
? ("streaming" as const)
: ("done" as const),
text: row.rawText,
type: "text" as const,
},
]
: []),
],
role: row.role,
})),
pending:
activeAssistant?.status === "queued" ||
activeAssistant?.status === "dispatching" ||
activeAssistant?.status === "running",
streaming,
};
};

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import {
extractThinkingMarkup,
getReasoningText,
isReasoningStreaming,
} from "./transforms";
import type { ConversationMessage } from "./types";
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
id: "message-1",
parts,
role: "assistant",
});
describe("reasoning traces", () => {
test("extracts completed and streaming MiniMax think blocks", () => {
expect(
extractThinkingMarkup(
"<think>First thought</think><think>Current thought"
)
).toBe("First thought\n\nCurrent thought");
});
test("combines native reasoning with inline thinking", () => {
expect(
getReasoningText(
message([
{ state: "streaming", text: "Native thought", type: "reasoning" },
{ state: "streaming", text: "<think>Inline thought", type: "text" },
])
)
).toBe("Native thought\n\nInline thought");
});
test("recognizes an open streamed think block as live reasoning", () => {
expect(
isReasoningStreaming(
message([{ state: "streaming", text: "<think>Working", type: "text" }])
)
).toBe(true);
});
});

View File

@@ -1,4 +1,4 @@
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
import type { AgentStatus, ConversationMessage } from "./types";
export const stripThinkingMarkup = (text: string): string => {
const withoutCompletedBlocks = text.replaceAll(
@@ -15,24 +15,61 @@ export const stripThinkingMarkup = (text: string): string => {
return visibleText.replaceAll("</think>", "").trimStart();
};
export const getMessageText = (message: FlueConversationMessage): string =>
stripThinkingMarkup(
export const extractThinkingMarkup = (text: string): string => {
const completed = [
...text.matchAll(/<think>(?<content>[\s\S]*?)<\/think>/giu),
]
.map((match) => match.groups?.content?.trim())
.filter((value): value is string => Boolean(value));
const lowerText = text.toLowerCase();
const openTagIndex = lowerText.lastIndexOf("<think>");
const closeTagIndex = lowerText.lastIndexOf("</think>");
if (openTagIndex > closeTagIndex) {
const active = text.slice(openTagIndex + "<think>".length).trim();
if (active) {
completed.push(active);
}
}
return completed.join("\n\n");
};
export const getRawMessageText = (message: ConversationMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
);
.join("\n");
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => {
if (part.type === "dynamic-tool") {
return part.state === "input-available";
}
return (
export const getMessageText = (message: ConversationMessage): string =>
stripThinkingMarkup(getRawMessageText(message));
export const getReasoningText = (message: ConversationMessage): string => {
const nativeReasoning = message.parts
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("\n");
const inlineReasoning = extractThinkingMarkup(getRawMessageText(message));
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
};
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
const nativeReasoningStreaming = message.parts.some(
(part) => part.type === "reasoning" && part.state === "streaming"
);
const rawText = getRawMessageText(message).toLowerCase();
const inlineReasoningStreaming =
rawText.lastIndexOf("<think>") > rawText.lastIndexOf("</think>") &&
message.parts.some(
(part) => part.type === "text" && part.state === "streaming"
);
return nativeReasoningStreaming || inlineReasoningStreaming;
};
export const isMessageStreaming = (message: ConversationMessage): boolean =>
message.parts.some(
(part) =>
(part.type === "text" || part.type === "reasoning") &&
part.state === "streaming"
);
});
export const getStatusDotClass = (status: AgentStatus): string => {
if (status === "error") {

View File

@@ -1,14 +1,43 @@
import type {
AgentStatus,
FlueConversationMessage,
FlueConversationPart,
} from "@flue/react";
export type AgentStatus =
| "connecting"
| "error"
| "idle"
| "streaming"
| "submitted";
export type ConversationPart =
| {
readonly state: "done" | "streaming";
readonly text: string;
readonly type: "text";
}
| {
readonly state: "done" | "streaming";
readonly text: string;
readonly type: "reasoning";
}
| {
readonly filename?: string;
readonly id?: string;
readonly mediaType: string;
readonly type: "file";
readonly url?: string;
};
export interface ConversationMessage {
readonly id: string;
readonly parts: ConversationPart[];
readonly role: "assistant" | "user";
}
export interface ChatAgentState {
error?: Error;
historyReady: boolean;
messages: FlueConversationMessage[];
sendMessage: (message: string) => Promise<void>;
messages: ConversationMessage[];
sendMessage: (
message: string,
options?: { readonly images?: readonly File[] }
) => Promise<void>;
status: AgentStatus;
}
@@ -20,7 +49,7 @@ export interface ChatComposerProps {
export interface ChatConversationProps {
historyReady: boolean;
messages: FlueConversationMessage[];
messages: ConversationMessage[];
onSuggestion: (suggestion: string) => void;
status: AgentStatus;
}
@@ -30,11 +59,7 @@ export interface ChatHeaderProps {
}
export interface ChatMessageProps {
message: FlueConversationMessage;
}
export interface ChatToolCallProps {
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
message: ConversationMessage;
}
export type AssistantResponseState = "thinking" | "writing";

View File

@@ -1,115 +0,0 @@
import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => {
const firstResponse = Promise.withResolvers<Response>();
const secondResponse = Promise.withResolvers<Response>();
const bothStarted = Promise.withResolvers<boolean>();
const capturedHeaders: Headers[] = [];
let requestCount = 0;
let requestId = 0;
const fetchImpl: typeof fetch = (input, init) => {
capturedHeaders.push(new Headers(init?.headers));
requestCount += 1;
if (requestCount === 2) {
bothStarted.resolve(true);
}
if (requestCount === 1) {
return firstResponse.promise;
}
if (requestCount === 2) {
return secondResponse.promise;
}
throw new Error(`Unexpected request: ${String(input)}`);
};
const client = createFlueClient({
baseUrl: BASE_URL.toString(),
fetch: createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => `request-${(requestId += 1)}`,
}),
headers: { authorization: "Bearer current-jwt" },
});
const firstSend = client.agents.send("zopu", "org-a", {
message: "first",
});
const secondSend = client.agents.send("zopu", "org-a", {
message: "second",
});
await bothStarted.promise;
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
firstResponse.resolve(
Response.json(
{
offset: "1",
streamUrl: "http://internal/streams/first",
submissionId: "submission-1",
},
{ status: 202 }
)
);
secondResponse.resolve(
Response.json(
{
offset: "2",
streamUrl: "http://internal/streams/second",
submissionId: "submission-2",
},
{ status: 202 }
)
);
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
{
offset: "1",
streamUrl: "https://flue.example/streams/first",
submissionId: "submission-1",
},
{
offset: "2",
streamUrl: "https://flue.example/streams/second",
submissionId: "submission-2",
},
]);
});
test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
const flueFetch = createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => "must-not-be-used",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
});
});

View File

@@ -1,82 +0,0 @@
interface FlueFetchOptions {
readonly baseUrl: URL;
readonly fetchImpl?: typeof fetch;
readonly generateRequestId?: () => string;
}
/**
* Build the Flue transport used by the browser client.
*
* The installed Flue SDK does not expose per-call headers on `agents.send`.
* Its custom `fetch` seam does receive the resolved URL and method, so request
* IDs are attached here to each agent admission POST. History and Durable
* Streams observation requests are GETs and intentionally remain untagged.
*/
export const createFlueFetch = ({
baseUrl,
fetchImpl = fetch,
generateRequestId = crypto.randomUUID,
}: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
return async (input, init) => {
const inputUrl =
typeof input === "string" || input instanceof URL ? input : input.url;
const requestUrl = new URL(inputUrl, baseUrl);
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
? requestUrl.pathname.slice(basePath.length)
: requestUrl.pathname;
const pathSegments = relativePath.split("/").filter(Boolean);
const isAgentAdmission =
method === "POST" &&
pathSegments.length === 3 &&
pathSegments[0] === "agents";
let requestInit = init;
if (isAgentAdmission) {
const headers = new Headers(
input instanceof Request ? input.headers : undefined
);
for (const [key, value] of new Headers(init?.headers).entries()) {
headers.set(key, value);
}
headers.set("x-zopu-request-id", generateRequestId());
requestInit = { ...init, headers };
}
const response = await fetchImpl(input, requestInit);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const body = (await response.clone().json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("streamUrl" in body) ||
typeof body.streamUrl !== "string"
) {
return response;
}
const streamUrl = new URL(body.streamUrl);
streamUrl.protocol = baseUrl.protocol;
streamUrl.host = baseUrl.host;
const headers = new Headers(response.headers);
headers.set("location", streamUrl.toString());
return Response.json(
{ ...body, streamUrl: streamUrl.toString() },
{
headers,
status: response.status,
statusText: response.statusText,
}
);
};
};

View File

@@ -1,186 +0,0 @@
import type { api } from "@code/backend/convex/_generated/api";
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "@code/primitives/project-work";
import type {
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "@code/ui/components/mobile-product";
import type { FunctionReturnType } from "convex/server";
type SignalList = FunctionReturnType<typeof api.signals.list>;
type ProjectEventList = FunctionReturnType<typeof api.projectIssues.events>;
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
interface BuildMobileWorkspaceViewInput {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
readonly assistant: MobileAssistantView;
readonly events: ProjectEventList | undefined;
readonly issues: readonly Doc<"projectIssues">[] | undefined;
readonly organizationLabel?: string;
readonly projects: readonly ProjectView[] | undefined;
readonly selectedProject: ProjectView | null;
readonly selectedWorkUnitId?: string;
readonly signals: SignalList | undefined;
}
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
day: "numeric",
month: "short",
});
const statusTone = (
status: ReturnType<typeof getProjectWorkStatus>
): MobileWorkUnitTone => {
if (status.phase === "completed") {
return "green";
}
if (status.phase === "failed" || status.phase === "waiting") {
return "orange";
}
if (status.phase === "captured") {
return "purple";
}
return "blue";
};
const isPullRequestData = (
value: unknown
): value is {
pullRequest: {
number: number;
status: "open" | "closed" | "merged";
url: string;
};
} =>
typeof value === "object" &&
value !== null &&
"pullRequest" in value &&
typeof value.pullRequest === "object" &&
value.pullRequest !== null &&
"number" in value.pullRequest &&
typeof value.pullRequest.number === "number" &&
"url" in value.pullRequest &&
typeof value.pullRequest.url === "string";
const toWorkUnit = (
issue: Doc<"projectIssues">,
artifactCount: number,
events: ProjectEventList | undefined
): MobileWorkUnitView => {
const status = getProjectWorkStatus(issue.status);
const pullRequest = events
?.filter((event) => event.issueId === issue._id)
.map((event) => event.data)
.find(isPullRequestData)?.pullRequest;
return {
artifactCount,
canRetry: status.phase === "failed" || status.phase === "waiting",
canStart: status.phase === "captured",
code: `#${issue.number}`,
id: issue._id,
nextAction: getProjectWorkNextAction(issue.status),
progress: getProjectWorkProgress(issue.status),
pullRequestNumber: pullRequest?.number,
reviewUrl: pullRequest?.url,
statusLabel: status.label,
summary: issue.body,
title: issue.title,
tone: statusTone(status),
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
};
};
const toLatestSignalView = (
signal: SignalList[number]["signal"] | undefined
): MobileSignalView | undefined => {
if (!signal) {
return;
}
return {
desiredOutcome: signal.problemStatement.desiredOutcome,
id: signal._id,
projectId: signal.projectId ?? undefined,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
};
};
const relevantSignalsForProject = (
signals: SignalList | undefined,
projectId: ProjectView["id"] | undefined
) =>
signals?.filter(
({ signal }) =>
!signal.projectId || String(signal.projectId) === String(projectId)
) ?? [];
const makeProjectViews = (
projects: readonly ProjectView[] | undefined
): readonly MobileProjectView[] =>
projects?.map((project) => ({
connected: project.sources.length > 0,
id: project.id,
name: project.name,
})) ?? [];
export const buildMobileWorkspaceView = ({
artifacts,
assistant,
events,
issues,
organizationLabel = "Personal workspace",
projects,
selectedProject,
selectedWorkUnitId,
signals,
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
const artifactCount = artifacts?.length ?? 0;
const workUnits =
issues?.map((issue) => toWorkUnit(issue, artifactCount, events)) ?? [];
const selectedWorkUnit =
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
workUnits.find((workUnit) => workUnit.tone === "blue") ??
workUnits[0];
const selectedProjectId = selectedProject?.id;
const relevantSignals = relevantSignalsForProject(signals, selectedProjectId);
const latestSignal = relevantSignals[0]?.signal;
const activeCount = workUnits.filter(
(workUnit) => workUnit.progress < 100
).length;
const needsAttentionCount = issues?.filter(
(issue) => issue.status === "failed" || issue.status === "needs-input"
).length;
const shippedCount = issues?.filter(
(issue) => issue.status === "completed"
).length;
const hasSelectedProject = Boolean(selectedProject);
const projectViews = makeProjectViews(projects);
return {
activeCount,
activityCount: workUnits.length + relevantSignals.length,
artifactCount,
assistant,
isLoading:
projects === undefined ||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
latestSignal: toLatestSignalView(latestSignal),
needsAttentionCount: needsAttentionCount ?? 0,
organizationLabel,
projectName: selectedProject?.name,
projects: projectViews,
selectedProjectId: selectedProject?.id,
selectedWorkUnit,
shippedCount: shippedCount ?? 0,
totalCount: workUnits.length,
workUnits,
};
};

View File

@@ -1,91 +0,0 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "./project-evidence";
const makeIssue = (
status: Doc<"projectIssues">["status"],
issueId: Id<"projectIssues"> = "issue-1" as Id<"projectIssues">
): Doc<"projectIssues"> =>
({
_creationTime: 1,
_id: issueId,
body: "Make the project loop visible in the web app.",
createdAt: 1000,
number: 8,
projectId: "project-1",
status,
title: "Build project loop UI",
updatedAt: 2000,
}) as Doc<"projectIssues">;
const makeArtifact = (content: string): Doc<"projectArtifacts"> =>
({
_creationTime: 1,
_id: "artifact-1",
content,
createdAt: 1000,
path: "artifacts.md",
projectId: "project-1",
revision: 2,
updatedAt: 2000,
}) as Doc<"projectArtifacts">;
describe("project evidence", () => {
test("turns published PR evidence into a review action", () => {
const view = buildProjectLoopView({
artifacts: [
makeArtifact(
"Verification: 14 unit tests passed.\nPull request: PR #42"
),
],
issue: makeIssue("completed"),
source: {
host: "git.example.com",
repositoryPath: "puter/zopu",
url: "https://git.example.com/puter/zopu",
},
});
expect(view?.progress).toBe(100);
expect(view?.verification).toBe("passed");
expect(view?.pullRequest.reviewUrl).toBe(
"https://git.example.com/puter/zopu/pulls/42"
);
expect(view?.verificationNote).toContain(
"Verification: 14 unit tests passed."
);
});
test("keeps the review slot pending when no PR is published", () => {
const view = buildProjectLoopView({
artifacts: [
makeArtifact("The run is recording implementation evidence."),
],
issue: makeIssue("working"),
source: undefined,
});
expect(view?.verification).toBe("running");
expect(view?.pullRequest.state).toBe("pending");
expect(view?.pullRequest.reviewUrl).toBeUndefined();
});
test("summarizes the project queue without inventing work", () => {
const summary = summarizeProjectIssues([
makeIssue("working"),
makeIssue("needs-input", "issue-2" as Id<"projectIssues">),
makeIssue("completed", "issue-3" as Id<"projectIssues">),
]);
expect(summary).toEqual({
active: 2,
completed: 1,
needsInput: 1,
total: 3,
});
});
});

View File

@@ -1,315 +0,0 @@
import type { Doc } from "@code/backend/convex/_generated/dataModel";
import type { ProjectIssueStatus } from "@code/primitives/project-issue";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "@code/primitives/project-work";
import type { ProjectVerificationStatus } from "@code/primitives/project-work";
type ProjectIssue = Doc<"projectIssues">;
type ProjectArtifact = Doc<"projectArtifacts">;
export interface ProjectSourceSummary {
readonly host: string;
readonly repositoryPath: string;
readonly url: string;
}
export interface ProjectIssueSummary {
readonly active: number;
readonly completed: number;
readonly needsInput: number;
readonly total: number;
}
export interface ProjectVerificationCheck {
readonly detail: string;
readonly label: string;
readonly state: "attention" | "complete" | "current" | "upcoming";
}
export interface ProjectActivityItem {
readonly detail: string;
readonly label: string;
readonly time: string;
}
export interface ProjectPullRequest {
readonly number: number | undefined;
readonly reviewUrl: string | undefined;
readonly state: "available" | "pending";
}
export interface ProjectLoopView {
readonly activity: readonly ProjectActivityItem[];
readonly currentState: string;
readonly nextAction: string;
readonly progress: number;
readonly pullRequest: ProjectPullRequest;
readonly status: ReturnType<typeof getProjectWorkStatus>;
readonly verification: ProjectVerificationStatus;
readonly verificationChecks: readonly ProjectVerificationCheck[];
readonly verificationNote: string;
}
const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
completed:
"Implementation and verification are complete. Review the resulting change.",
failed:
"The run stopped with its workspace preserved. Review the failure and retry when ready.",
"needs-input":
"The run is paused until a project decision or missing piece is resolved.",
open: "The issue is ready to start. Zopu will keep the project context attached to the run.",
queued: "The issue is queued for the project manager to pick up.",
working:
"The project manager is working through the issue and recording durable evidence.",
};
const formatTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
const relativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
const allArtifactText = (artifacts: readonly ProjectArtifact[]): string =>
artifacts.map((artifact) => artifact.content).join("\n");
const extractPullRequest = (
artifacts: readonly ProjectArtifact[],
source: ProjectSourceSummary | undefined
): ProjectPullRequest => {
const text = allArtifactText(artifacts);
const urlMatch = text.match(/https?:\/\/[^\s)]+\/pulls\/\d+/iu)?.[0];
const numberMatch = text.match(/\bPR\s*#(?<number>\d+)\b/iu);
const numberValue = numberMatch?.groups?.number;
const number = numberValue ? Number(numberValue) : undefined;
let reviewUrl = urlMatch;
if (!reviewUrl && number && source) {
reviewUrl = `${source.url.replace(/\/$/u, "")}/pulls/${number}`;
}
return {
number,
reviewUrl,
state: reviewUrl ? "available" : "pending",
};
};
const extractVerificationNote = (
artifacts: readonly ProjectArtifact[],
status: ProjectIssueStatus
): string => {
const evidenceLine = allArtifactText(artifacts)
.split("\n")
.map((line) => line.trim())
.find((line) => /verification|tests?|browser|passed|failed/iu.test(line));
if (evidenceLine) {
return evidenceLine.replace(/^[-*#\s]+/u, "").slice(0, 140);
}
if (status === "completed") {
return "The agent reported completion after its verification step.";
}
if (status === "failed") {
return "Verification did not complete successfully.";
}
if (status === "working") {
return "Verification will appear here as the run records evidence.";
}
return "No verification evidence has been recorded yet.";
};
const makeRunCheck = (status: ProjectIssueStatus): ProjectVerificationCheck => {
if (status === "queued") {
return {
detail: "Agent admission is queued",
label: "Agent run",
state: "current",
};
}
if (status === "open") {
return {
detail: "Start the issue to create a run",
label: "Agent run",
state: "upcoming",
};
}
return {
detail: "Project manager run is attached",
label: "Agent run",
state: "complete",
};
};
const makeVerificationCheck = (
verification: ProjectVerificationStatus
): ProjectVerificationCheck => {
if (verification === "passed") {
return {
detail: "Verification passed",
label: "Verification",
state: "complete",
};
}
if (verification === "failed") {
return {
detail: "Review the preserved failure",
label: "Verification",
state: "attention",
};
}
if (verification === "blocked") {
return {
detail: "Waiting on a project decision",
label: "Verification",
state: "attention",
};
}
if (verification === "running") {
return {
detail: "Evidence is being recorded",
label: "Verification",
state: "current",
};
}
return {
detail: "No verification result yet",
label: "Verification",
state: "upcoming",
};
};
const makeChecks = (
status: ProjectIssueStatus,
verification: ProjectVerificationStatus,
pullRequest: ProjectPullRequest
): readonly ProjectVerificationCheck[] => {
const issueCheck: ProjectVerificationCheck =
status === "open"
? {
detail: "Waiting to be started",
label: "Issue accepted",
state: "upcoming",
}
: {
detail: "Issue context is attached",
label: "Issue accepted",
state: "complete",
};
const pullRequestCheck: ProjectVerificationCheck =
pullRequest.state === "available"
? {
detail: "Ready for human review",
label: "Gitea pull request",
state: "complete",
}
: {
detail: "Appears after a verified run publishes one",
label: "Gitea pull request",
state: "upcoming",
};
return [
issueCheck,
makeRunCheck(status),
makeVerificationCheck(verification),
pullRequestCheck,
];
};
const buildActivity = (
issue: ProjectIssue,
artifacts: readonly ProjectArtifact[],
statusLabel: string
): readonly ProjectActivityItem[] => {
const artifact = [...artifacts]
.toSorted((left, right) => right.updatedAt - left.updatedAt)
.find((candidate) => candidate.path !== "work.md");
const items: ProjectActivityItem[] = [
{
detail: `Issue #${issue.number} entered the project loop`,
label: "Issue created",
time: formatTime(issue.createdAt),
},
];
if (issue.updatedAt !== issue.createdAt) {
items.unshift({
detail: `Status is now ${statusLabel.toLowerCase()}`,
label: "Work state updated",
time: relativeTime(issue.updatedAt),
});
}
if (artifact) {
items.unshift({
detail: `Durable project evidence changed in ${artifact.path}`,
label: "Evidence recorded",
time: relativeTime(artifact.updatedAt),
});
}
return items.slice(0, 3);
};
export const summarizeProjectIssues = (
issues: readonly ProjectIssue[] | undefined
): ProjectIssueSummary => {
if (!issues) {
return { active: 0, completed: 0, needsInput: 0, total: 0 };
}
return {
active: issues.filter(
(issue) => !["completed", "failed"].includes(issue.status)
).length,
completed: issues.filter((issue) => issue.status === "completed").length,
needsInput: issues.filter((issue) => issue.status === "needs-input").length,
total: issues.length,
};
};
export const buildProjectLoopView = ({
artifacts,
issue,
source,
}: {
readonly artifacts: readonly ProjectArtifact[] | undefined;
readonly issue: ProjectIssue | undefined;
readonly source: ProjectSourceSummary | undefined;
}): ProjectLoopView | null => {
if (!issue) {
return null;
}
const status = getProjectWorkStatus(issue.status);
const { verification } = status;
const resolvedArtifacts = artifacts ?? [];
const pullRequest = extractPullRequest(resolvedArtifacts, source);
return {
activity: buildActivity(issue, resolvedArtifacts, status.label),
currentState: STATUS_TEXT[issue.status],
nextAction: getProjectWorkNextAction(issue.status),
progress: getProjectWorkProgress(issue.status),
pullRequest,
status,
verification,
verificationChecks: makeChecks(issue.status, verification, pullRequest),
verificationNote: extractVerificationNote(resolvedArtifacts, issue.status),
};
};

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { getUserInitials } from "./get-user-initials";
describe("getUserInitials", () => {
it("uses the first two name parts", () => {
expect(getUserInitials("Sai Karthik")).toBe("SK");
});
it("uses a neutral fallback while auth loads", () => {
expect(getUserInitials()).toBe("U");
});
});

View File

@@ -0,0 +1,10 @@
export const getUserInitials = (name?: string): string => {
const parts = name?.trim().split(/\s+/u).filter(Boolean) ?? [];
if (parts.length === 0) {
return "U";
}
return parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
};

View File

@@ -0,0 +1,78 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Workspace frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
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(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/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(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"'
);
});
test("keeps attachment strips contained and markdown capabilities enabled", () => {
const attachment = source("../../components/chat/chat-attachments.tsx");
const attachmentPrimitive = source(
"../../../../../packages/ui/src/components/attachment.tsx"
);
const chatMessage = source("../../components/chat/chat-message.tsx");
const messageRenderer = source(
"../../../../../packages/ui/src/components/ai-elements/message.tsx"
);
expect(attachment).toContain('className="max-w-full');
expect(attachment).toContain("useAuthenticatedImageSource");
expect(attachment).toContain(`authorization: \`Bearer \${accessToken}\``);
expect(attachmentPrimitive).toContain("overflow-x-auto");
expect(chatMessage).toContain("<MessageAttachments parts={fileParts} />");
expect(chatMessage).toContain("<MessageResponse");
expect(messageRenderer).toContain("const streamdownPlugins = {");
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

@@ -0,0 +1,53 @@
import type { WorkNotice } from "@code/primitives/work";
import { describe, expect, test } from "vitest";
import type { ConversationMessage } from "@/lib/chat/types";
import {
buildWorkspaceTimeline,
findSourceMessageTarget,
} from "./presentation";
const textMessage = (
id: string,
role: "assistant" | "user",
text: string
): ConversationMessage => ({
id,
parts: [{ state: "done", text, type: "text" }],
role,
});
const notice: WorkNotice = {
createdAt: 10,
eventId: "event-1",
sourceMessageIds: ["source-1"],
sourceTexts: ["Build the phone flow."],
workId: "work-1",
};
describe("Workspace presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildWorkspaceTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
],
[notice]
);
expect(
timeline.map((item) =>
item.kind === "message" ? item.message.id : item.notice.workId
)
).toEqual(["user-1", "assistant-1", "work-1"]);
});
test("finds the exact source message without trimming it", () => {
const messages = [textMessage("user-1", "user", " Exact source ")];
expect(findSourceMessageTarget(messages, " Exact source ")).toBe(
"user-1"
);
expect(findSourceMessageTarget(messages, "Exact source")).toBeUndefined();
});
});

View File

@@ -0,0 +1,75 @@
import type { WorkNotice } from "@code/primitives/work";
import {
getMessageText,
getRawMessageText,
getReasoningText,
} from "@/lib/chat/transforms";
import type { ConversationMessage } from "@/lib/chat/types";
import type { WorkspaceTimelineItem } from "./types";
export const isVisibleConversationMessage = (
message: ConversationMessage
): boolean =>
message.role === "user" ||
getMessageText(message).length > 0 ||
getReasoningText(message).length > 0;
const targetIndexForNotice = (
messages: readonly ConversationMessage[],
notice: WorkNotice
): number => {
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
const index = messages.findIndex(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
);
return index === -1 ? [] : [index];
});
if (sourceIndexes.length === 0) {
return messages.length - 1;
}
const sourceIndex = Math.max(...sourceIndexes);
const responseOffset = messages
.slice(sourceIndex + 1)
.findIndex((message) => message.role === "assistant");
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildWorkspaceTimeline = (
allMessages: readonly ConversationMessage[],
notices: readonly WorkNotice[]
): readonly WorkspaceTimelineItem[] => {
const messages = allMessages.filter(isVisibleConversationMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
const atTarget = noticesByMessageIndex.get(targetIndex) ?? [];
atTarget.push(notice);
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: WorkspaceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {
timeline.push({ kind: "work", notice });
}
}
if (messages.length === 0) {
for (const notice of noticesByMessageIndex.get(-1) ?? []) {
timeline.push({ kind: "work", notice });
}
}
return timeline;
};
export const findSourceMessageTarget = (
messages: readonly ConversationMessage[],
sourceText: string
): string | undefined =>
messages.find(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
)?.id;

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

@@ -1,11 +1,8 @@
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { WebAuthProvider } from "@code/auth/web";
import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
import "./index.css";
import { createFlueClient } from "@flue/sdk";
import { useMemo } from "react";
import { LoaderCircle } from "lucide-react";
import {
isRouteErrorResponse,
Links,
@@ -13,11 +10,14 @@ import {
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider";
import { createFlueFetch } from "./lib/flue-transport";
import { loadAuthToken } from "./lib/auth.server";
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
@@ -32,39 +32,14 @@ export const links: Route.LinksFunction = () => [
},
];
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
const AuthenticatedFlueProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const resolveAccessToken = useConvexAccessToken();
const client = useMemo(
() =>
createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
headers: async () => {
const accessToken = await resolveAccessToken();
const headers: Record<string, string> = {};
if (accessToken) {
headers.authorization = `Bearer ${accessToken}`;
}
return headers;
},
}),
[resolveAccessToken]
);
return <FlueProvider client={client}>{children}</FlueProvider>;
};
export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<Meta />
<Links />
</head>
@@ -76,9 +51,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
</html>
);
const App = () => (
<WebAuthProvider>
<AuthenticatedFlueProvider>
const RouteProgress = () => {
const navigation = useNavigation();
if (!navigation.location) {
return null;
}
return (
<output
aria-label="Loading page"
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
>
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
</output>
);
};
export const HydrateFallback = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
<div className="flex items-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu
</div>
</main>
);
const App = ({ loaderData }: Route.ComponentProps) => (
<WebAuthProvider initialToken={loaderData.token}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
@@ -86,10 +83,10 @@ const App = () => (
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<RouteProgress />
<Outlet />
<Toaster richColors />
</ThemeProvider>
</AuthenticatedFlueProvider>
</WebAuthProvider>
);

View File

@@ -7,13 +7,7 @@ export default [
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),
route("dashboard", "./routes/app/dashboard/page.tsx"),
route("todos", "./routes/app/todos/page.tsx"),
index("./routes/app/workspace/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
] satisfies RouteConfig;

View File

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

View File

@@ -1,29 +1,35 @@
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet, useLocation } 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";
export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request);
export default function AppLayout() {
const auth = useWebAuth();
const location = useLocation();
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
const organization = usePersonalOrganization();
const navigate = useNavigate();
const projects = useQuery(
api.projects.list,
organization.organizationId ? {} : "skip"
);
}
if (auth.status === "inconsistent") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Your session could not be verified
</div>
);
}
if (auth.status === "unauthenticated") {
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
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,12 +0,0 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
export default function MobileChatPage() {
return <MobileFlowPage screen="assistant-chat" />;
}

View File

@@ -1,9 +0,0 @@
import { Outlet } from "react-router";
export default function MobileProductLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
}

View File

@@ -1,15 +0,0 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Active work | Zopu" },
{
content: "Browse active work and expand a work unit in place",
name: "description",
},
];
export default function MobileWorkListPage() {
return <MobileFlowPage screen="work-list" />;
}

View File

@@ -1,15 +0,0 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work unit | Zopu" },
{
content: "Review the state, next milestone, and activity for a work unit",
name: "description",
},
];
export default function MobileWorkUnitPage() {
return <MobileFlowPage screen="work-unit-detail" />;
}

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

@@ -1,123 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = newTodoText.trim();
if (!text) {
return;
}
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
void toggleTodo({ completed: !currentCompleted, id });
};
const handleDeleteTodo = (id: Id<"todos">) => {
void deleteTodo({ id });
};
let todoContent: ReactNode;
if (todos === undefined) {
todoContent = (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
} else if (todos.length === 0) {
todoContent = (
<p className="py-4 text-center">No todos yet. Add one above!</p>
);
} else {
todoContent = (
<ul className="space-y-2">
{todos.map((todo) => (
<li
className="flex items-center justify-between rounded-md border p-2"
key={todo._id}
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
id={`todo-${todo._id}`}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
/>
<label
className={
todo.completed
? "text-muted-foreground line-through"
: undefined
}
htmlFor={`todo-${todo._id}`}
>
{todo.text}
</label>
</div>
<Button
aria-label="Delete todo"
onClick={() => handleDeleteTodo(todo._id)}
size="icon"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
);
}
return (
<div className="mx-auto w-full max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form
className="mb-6 flex items-center space-x-2"
onSubmit={handleAddTodo}
>
<Input
onChange={(event) => setNewTodoText(event.target.value)}
placeholder="Add a new task..."
value={newTodoText}
/>
<Button disabled={!newTodoText.trim()} type="submit">
Add
</Button>
</form>
{todoContent}
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@@ -1,21 +1,13 @@
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet } from "react-router";
import { Outlet } from "react-router";
import { redirectAuthenticated } from "@/lib/auth.server";
import type { Route } from "./+types/layout";
export const loader = ({ request }: Route.LoaderArgs) =>
redirectAuthenticated(request);
export default function AuthLayout() {
const auth = useWebAuth();
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (auth.status === "authenticated") {
return <Navigate replace to="/" />;
}
return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
<div className="w-full max-w-sm">

View File

@@ -1,5 +1,5 @@
import { LoginForm } from "@code/auth/web";
import { useNavigate } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import type { Route } from "./+types/page";
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
export default function LoginRoute() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
return (
<LoginForm
onSuccess={() =>
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
}
/>
);
}

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