# Warlock AI — full skills > Package: `@warlock.js/ai` > Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/ai/skills/`. Re-run `node scripts/generate-llms.mjs` after any change. ## ai-basics `@warlock.js/ai/ai-basics/SKILL.md` --- name: ai-basics description: 'Start with @warlock.js/ai — provider-agnostic core for agents / tools / workflows / supervisors / orchestrators. 4-primitive ladder (agent → workflow → supervisor → orchestrator, all shipped) plus planner, memory, stores, DX helpers, and the optional @warlock.js/ai-panoptic observability sidecar. Every primitive returns {data, error, usage, report}. Triggers: `ai.agent`, `ai.tool`, `ai.workflow`, `ai.supervisor`, `ai.orchestrator`, `ai.planner`, `ai.memory`, `ai.systemPrompt`, `ExecuteResult`, `BaseReport`, `AIError`, `panoptic`; ''which AI primitive do I use'', ''what is warlock ai'', ''pick an AI skill'', ''how do I observe / trace AI runs''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent details — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langchain`, `llamaindex`, `ai` (Vercel SDK); raw `openai` / `@anthropic-ai/sdk`.' --- # AI foundations Provider-agnostic core for building AI primitives in TypeScript. Adapters live in sibling packages — all five first-party adapters ship today: `@warlock.js/ai-openai`, `-anthropic`, `-bedrock`, `-google`, `-ollama`. > This skill is the AI **map** — read it first, then load the specific skill for the task. ## The 4-primitive ladder ``` ai.agent() → single task, stateless [shipped] ai.workflow() → static predefined steps, resumable [shipped] ai.supervisor() → multi-agent dynamic routing, resumable [shipped] ai.orchestrator() → durable session — state/history/resume [shipped] ``` Each primitive is an escape hatch to the next level of complexity. Users start low, graduate upward only when needed. Every primitive returns the same result envelope — canonical destructure `{ data, error, usage, report }` (the shared `BaseResult` guarantees `usage` + optional `error`; each primitive adds `data` + `report`). Workflows, supervisors, and orchestrators expose `.asTool()` so an agent can call them inside its tool loop; raw executables also auto-adapt when dropped into an agent's `tools: []`. Compose freely. Beyond the ladder: `ai.planner()` (LLM-generated plans), `ai.memory()` (working + semantic recall), `ai.batch()` / `ai.fallbackModel()` / `ai.router()` / `ai.fanOut()` (DX helpers), `agent.eval()` (scoring), and the `ai.checkpoint.*` / `ai.snapshot.*` orchestrator stores. ## Foundations 1. **Public API is functional factories.** Use `ai.agent({...})`, `ai.tool({...})`, `ai.workflow({...})`, `ai.step({...})`, `ai.supervisor({...})`, `ai.systemPrompt()`, `ai.persona()`, `ai.instruction()`. Never `new Agent()`. 2. **Adapter entry points are classes.** `new OpenAISDK({ apiKey })` from [`@warlock.js/ai-openai/setup-openai/SKILL.md`](@warlock.js/ai-openai/setup-openai/SKILL.md). 3. **Schemas everywhere are `StandardSchemaV1`.** Recommended: [`@warlock.js/seal`](@warlock.js/seal/seal-basics/SKILL.md) — `v.object({...})`. Zod, Valibot, hand-rolled all interop. 4. **`execute()` never throws.** Errors funnel into `result.error` as a typed `AIError` subclass. Same for `stream.result`, `workflow.execute()` / `resume()`, `supervisor.execute()` / `resume()`. See [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md). 5. **Each `execute()` call is isolated.** Fresh internal execution instance per call. 6. **Every error is an `AIError`.** Plain `Error` never leaks. Branch on `error.code` (stable string), `error.category` (coarse), or `instanceof`. 7. **Result shape is uniform.** `{ data, error, usage, report }` across every primitive. `report` is a recursive `BaseReport` tree. 8. **Persistence is delegated** to `@warlock.js/cache`. See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md). 9. **Logging is delegated** to `@warlock.js/logger`. See [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md). 10. **`name` on agents is optional.** Anonymous agents get a deterministic `anon__` fingerprint. 11. **Every report carries lineage** — `rootRunId` + `parentRunId` + `reportSchemaVersion: 1`. 12. **`version` is dev-curated, `sessionId` is caller-supplied** — both propagate through nested reports. 13. **Cost is computed at emit time as a per-channel breakdown.** Set `pricing` on the model adapter; `Usage.cost` carries `{ input, output, cachedInput?, cachedOutput? }` per trip, rolled up bottom-up. 14. **Every `AIError` carries a coarse `category`** for retry-policy dispatch (`rate-limit`, `auth`, `content-filter`, `schema`, etc.). ## 30-second example ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const myAgent = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) }); const { data, text, report, usage, error } = await myAgent.execute("Hello"); if (error) /* typed AIError */ ; console.log(text, usage.total, report.duration); ``` ## Pick a skill | If the task is about… | Load | | --- | --- | | `ai.agent({...})` — single-LLM-turn primitive, structured output, streaming, attachments, `spawnSubAgent` | [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) | | `ai.tool({...})` — typed validated functions the model can call | [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) | | `ai.systemPrompt()` / `ai.persona()` / `ai.instruction()` — composable prompts with placeholders | [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) | | `ai.workflow({...})` — durable resumable pipelines with steps, routing, retry | [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) | | `ai.supervisor({...})` — multi-intent routing, fan-out, evaluate loops | [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) | | `ai.orchestrator({...})` — durable stateful sessions, drift, compaction, resume | [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md) | | `ai.planner({...})` — LLM-generated plans over registered capabilities | [`@warlock.js/ai/run-planner/SKILL.md`](@warlock.js/ai/run-planner/SKILL.md) | | `ai.memory({...})` — working + semantic recall for agents / sessions | [`@warlock.js/ai/use-ai-memory/SKILL.md`](@warlock.js/ai/use-ai-memory/SKILL.md) | | `ai.checkpoint.*` / `ai.snapshot.*` — orchestrator session + run stores | [`@warlock.js/ai/manage-ai-stores/SKILL.md`](@warlock.js/ai/manage-ai-stores/SKILL.md) | | DX helpers — `batch` / `fallbackModel` / `eval` + matchers / SLO contracts / `fromFile` | [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md) | | `sdk.embedder({...})` — text-to-vector for RAG tools, vector ingest | [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md) | | Agent + supervisor middleware — `budget` / `guardrail` / `semanticCache` + custom hooks | [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) | | Snapshot resume + semantic cache via `@warlock.js/cache` | [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) | | Configuring framework logging | [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md) | | `AIError` hierarchy, `error.code` / `error.category`, retry patterns (incl. `ORCHESTRATOR_*` / `PLANNER_*` families) | [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) | | Provider adapters + cost truth (pricing / cache + reasoning tokens / capabilities) | [`@warlock.js/ai/pick-ai-provider/SKILL.md`](@warlock.js/ai/pick-ai-provider/SKILL.md) | | Observability — `panoptic()` subscriber, queryable trace store, OTEL / Langfuse / console / file exporters | [`@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md`](@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md) | ## Package layout ``` @warlock.js/ai — agent, tool, workflow, supervisor, system-prompt, errors, middleware @warlock.js/ai-openai — OpenAI SDK adapter (model + embedder); also OpenRouter / Azure via baseURL @warlock.js/ai-anthropic — Anthropic / Claude adapter (Messages API) @warlock.js/ai-bedrock — AWS Bedrock adapter (Converse API + Titan embeddings) @warlock.js/ai-google — Google / Gemini adapter (@google/genai + batch embeddings) @warlock.js/ai-ollama — Ollama adapter for local models @warlock.js/ai-panoptic — observability sidecar: panoptic() subscriber → collector → queryable trace store + console / file / OTEL / Langfuse exporters ``` The observability sidecar is OPTIONAL and lives in its own package — it subscribes to the report tree every primitive already emits, so you wire `panoptic(...)` once and never touch primitive code. Load [`@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md`](@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md) for collecting / querying traces and [`@warlock.js/ai-panoptic/export-traces/SKILL.md`](@warlock.js/ai-panoptic/export-traces/SKILL.md) for OTEL / Langfuse / console / file exporters. Runtime deps: `@warlock.js/cache` (persistence), `@warlock.js/logger` (logging), `@warlock.js/seal` (recommended schema lib). ## When NOT to use this skill - Code importing `openai` / `@anthropic-ai/sdk` directly without going through `@warlock.js/ai` — those are raw provider SDKs. - Generic JS/TS questions unrelated to agent / tool / workflow / supervisor wiring. ## Design references - `domains/ai/design/decisions.md` — locked architectural decisions with rationale - `domains/ai/design/workflow.md` — workflow spec - `domains/ai/design/supervisor.md` — supervisor spec - `domains/ai/design/execution-result.md` — unified `ExecuteResult` + recursive `BaseReport` tree - `domains/ai/conventions/errors.md` — framework-vs-consumer-app error split ## ai-dx-helpers `@warlock.js/ai/ai-dx-helpers/SKILL.md` --- name: ai-dx-helpers description: 'Developer-experience helpers across @warlock.js/ai — ai.batch (fan-out an executable over a dataset w/ concurrency + per-item retry), ai.fallbackModel (ordered model failover), agent.eval + ai.eval scorers + Vitest matchers (registerAiMatchers / toRouteTo / toConverge / toPassStep / toOutputShape) + ai.mockRouter, SLO/cost budget contracts (ai.middleware.budget({contract}) + readBudgetFallbackSignal), supervisor-level middleware, ai.systemPrompt.fromFile, and auto-adapt executables in tools:[]. Triggers: `ai.batch`, `BatchResult`, `ai.fallbackModel`, `FallbackModelContract`, `agent.eval`, `ai.eval`, `EvalReport`, `EvalScorer`, `ai.eval.judge`, `registerAiMatchers`, `toRouteTo`, `toConverge`, `toPassStep`, `toOutputShape`, `ai.mockRouter`, `MockSDK`, `mockAgent`, `budget({contract})`, `BudgetContract`, `maxLatencyMs`, `onViolation`, `readBudgetFallbackSignal`, `supervisor middleware`, `systemPrompt.fromFile`; ''run an agent over a list'', ''fail over to a backup model'', ''evaluate / score an agent'', ''SLO budget'', ''test a supervisor without an LLM'', ''prompt from a file''; typical import `import { ai } from "@warlock.js/ai"`. Skip: core agent lifecycle — `@warlock.js/ai/run-ai-agent/SKILL.md`; the budget/guardrail/semanticCache basics — `@warlock.js/ai/attach-ai-middleware/SKILL.md`; competing libs `promptfoo`, `langsmith`.' --- # DX helpers — batch, fallback, eval, SLO, supervisor middleware A grab-bag of additive 4.3.0 helpers. Each is independent — load the section you need. ## `ai.batch(executable, items, options?)` — fan-out a dataset Runs the SAME executable (agent / workflow / supervisor / tool — anything `ExecutableContract`) N times, once per item, with bounded concurrency and per-item retry. Aggregates into the unified `ExecuteResult` envelope so a batch slots into cost dashboards exactly like a single run. ```ts const result = await ai.batch(summarizer, articles, { concurrency: 4, // default = items.length (all at once); <=0 → serial retry: { attempts: 3, backoff: "exponential" }, // workflow RetryConfig, applied per item onItem: (item) => log.info("batch", "item", "settled", { index: item.index }), signal: AbortSignal.timeout(120_000), sessionId: "ingest-2026-06-19", // lineage onto every child report name: "summarize-articles", }); console.log(`${result.report.succeeded}/${result.report.total} ok`); console.log(`${result.usage.total} tokens total`); for (const item of result.items) { if (item.status === "completed") console.log(item.index, item.result?.data); else console.warn(item.index, item.error?.code, "after", item.attempts, "attempts"); } ``` **Isolation.** Items are independent — one item's failure (after its retries) never cancels a sibling, and **the batch never rejects as a whole** (`result.error` stays undefined). Failures live on each `BatchItemResult` (`status: "completed" | "failed" | "cancelled"`, `error`, `attempts`). `result.data` is the positional array of successful items' `.data` with `undefined` in failed/cancelled slots. Usage rolls up bottom-up (batch has zero own cost); each item's report attaches under `report.children[]` in original order. An `onItem` throw is swallowed — a progress hook never breaks the batch. ## `ai.fallbackModel(models, options?)` — ordered model failover A drop-in `ModelContract` that wraps an ordered list and advances to the next model only on a **transient** provider error. ```ts const model = ai.fallbackModel([ openai.model({ name: "gpt-4o" }), anthropic.model({ name: "claude-3-5-sonnet" }), ]); const agent = ai.agent({ model }); // hand it anywhere a model goes // custom retry predicate or code list: ai.fallbackModel([primary, backup], { retryOn: ["PROVIDER_RATE_LIMIT", "PROVIDER_TIMEOUT"] }); ai.fallbackModel([primary, backup], { retryOn: (error) => error instanceof ProviderError }); ``` Default retryable codes: `PROVIDER_RATE_LIMIT`, `PROVIDER_TIMEOUT`, `PROVIDER_ERROR`. Auth / invalid-request / context-length / content-filter re-throw immediately (they'd fail identically downstream — retrying only burns budget). Identity/capabilities/pricing front the primary model. Usage aggregates across attempted models. Inspect `model.lastAttempts` for the failed models of the most recent call. **Streaming caveat:** `stream()` can only fail over while no chunk has been emitted yet — once the first `delta` / `tool-call` reaches the consumer, a mid-stream failure propagates instead of restarting. It advances *instantly* (no backoff) — pair with a backoff middleware if you want delay. ## `agent.eval(options)` + `ai.eval.*` scorers — evaluate an agent Run a suite of cases through `agent.execute()` and score each. ```ts const report = await myAgent.eval({ cases: [ { name: "capital", input: "Capital of Egypt?", expected: "Cairo" }, { name: "tone", input: "Comfort an upset user." }, // judge-scored ], scorers: [ai.eval.contains()], // default scorers for cases w/o their own judge: { agent: judgeAgent, rubric: "Score 1.0 only if empathetic." }, // LLM-as-judge fallback passThreshold: 0.5, // default onFailure: (caseResult) => snapshot(caseResult), }); expect(report.passed).toBe(true); // true only when EVERY case passed report.passRate; report.meanScore; report.cases; // drill-down ``` Built-in scorers on `ai.eval.*`: `exact()` (trimmed, case-insensitive; structured compared by canonical JSON), `contains()` (substring), `predicate(fn)` (arbitrary boolean assertion), `judge(config)` (LLM-as-judge). Scorer precedence per case: the case's own `scorers` → suite `scorers` → synthesized judge. A case with NONE throws at author time. A case passes only when the agent did not error AND every scorer passed. ## Vitest matchers + `ai.mockRouter` — test report trees ```ts import { registerAiMatchers } from "@warlock.js/ai"; registerAiMatchers(); // once per test file (idempotent) expect(await supervisor.execute(input)).toRouteTo("critic"); // dispatched the named intent expect(await supervisor.execute(input)).toConverge(); // terminated cleanly on own decision expect(await workflow.execute(input)).toPassStep("draft"); // named step completed expect(await agent.execute(input, { output: schema })).toOutputShape(schema); // data validates ``` The pure verdict functions (`matchConverge`, `matchOutputShape`, `matchPassStep`, `matchRouteTo`) and `AiMatchers` ship eagerly with no `vitest` coupling; only `registerAiMatchers` lazily imports `vitest` (a devDependency), so importing `@warlock.js/ai` in production never pulls in `vitest`. `ai.mockRouter(decisions, options?)` builds a deterministic `route` callback that replays a canned sequence — one decision per supervisor iteration — for testing supervisors without an LLM router: ```ts import { END } from "@warlock.js/ai"; ai.supervisor({ name: "draft-then-review", intents: { writer, critic }, route: ai.mockRouter(["writer", "critic", END]), }); // branch on state, repeat the last decision until done: ai.mockRouter(["research", (ctx) => (ctx.state.summary ? END : "research")], { onExhausted: "repeat" }); ``` A decision is a literal `Next` (intent name / fan-out array / `END`) or a predicate over the live `RouteContext`. On exhaustion: `"end"` (default — terminate), `"throw"` (test failure), `"repeat"` (replay last). For a scripted LLM, use `MockSDK` (script the model output) and `mockAgent({ name, responses })` for fixed-response capabilities. ## SLO / cost budget contracts — `ai.middleware.budget({ contract })` On top of the legacy `maxTokens` / `maxCostUSD` caps, declare a run-level SLO as data, with one global reaction: ```ts const guard = ai.middleware.budget({ pricing: { "gpt-4o": { inputPer1K: 0.005, outputPer1K: 0.015 } }, contract: { maxCostUSD: 0.05, maxLatencyMs: 8_000, // wall-clock from first execute.before to each trip.after maxTokens: 40_000, onViolation: "fallback", // "abort" (default) hard-stops; "fallback" records a signal + continues fallback: (violation) => routeToCheaperModel(violation.dimension), }, }); ``` Every clause is optional (a contract with no caps is inert). `onViolation: "abort"` throws `BudgetExceededError` at the next trip boundary; `"fallback"` does NOT abort — it records a typed `BudgetContractViolation` and fires `fallback`, letting the run continue (the middleware can't itself swap models). A latency breach has no `BudgetUnit` — its numbers surface via the error's `context.dimension`. `maxCostUSD` still needs a `pricing` entry for the running model or it degrades silently. Read a recorded fallback signal in an outer middleware's `execute.after`: ```ts import { readBudgetFallbackSignal } from "@warlock.js/ai"; const signal = readBudgetFallbackSignal(ctx.state); // pass the middleware name as 2nd arg if non-default if (signal?.dimension === "cost") await rerunOnCheaperModel(); ``` ## Supervisor-level middleware The `middleware: AgentMiddleware[]` array on `ai.supervisor({...})` fires each middleware's optional `supervisor` hook map (`before` / `after` / `onError`) ONCE around the entire `execute()` / `stream()` / `resume()` run — the supervisor-level peer of an agent's `execute`-level middleware. ```ts ai.supervisor({ name: "support", router, intents, middleware: [auditTrail] }); ``` Same onion semantics as the agent pipeline: `before` runs top-down (return a `SupervisorResult` to short-circuit, throw to abort), `after` / `onError` run bottom-up. A middleware without a `supervisor` hook map is skipped — so the same builtin objects (budget, guardrail, …) can be registered on agents AND on the supervisor, each declaring whichever level applies. Each needs a unique `name` (its `ctx.state` namespace). ## `ai.systemPrompt.fromFile(path)` Build a system prompt by reading a file **once, synchronously, at construction** — the file's UTF-8 contents seed one instruction block, so placeholders inside resolve at `resolve()` time and the result forks with further `.persona()` / `.instruction()` calls. ```ts const prompt = ai.systemPrompt.fromFile("./prompts/support-agent.md"); const localized = prompt.instruction("Respond in {{language|English}}."); localized.resolve({ language: "Arabic" }); ``` One-shot by design (never re-read on `resolve()`). Throws `InvalidRequestError` when the file can't be read — a typo in the path fails loudly at construction instead of producing an empty prompt. `SystemPrompt.fromFile(path)` and `ai.systemPrompt.fromFile(path)` are identical. ## Auto-adapt executables in `tools: []` An agent's `tools` array accepts a raw executable primitive (`AgentContract` / `WorkflowInstance` / `SupervisorContract` / orchestrator) directly — it is auto-adapted into a `ToolContract` at factory time. The tool manifest is derived from the executable's `name` + `description` + (optional) `inputSchema`; dispatch flows through its `execute()`. ```ts const support = ai.supervisor({ name: "support", inputSchema: v.object({ message: v.string() }), router, intents }); const concierge = ai.agent({ model, tools: [support, billingWorkflow, lookupTool], // no .asTool() needed }); ``` `.asTool()` still works and takes precedence when you need a custom name / schema per use. For a supervisor/orchestrator, declaring `inputSchema` on the config is what lets it drop straight into `tools: []`. ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `agent.eval`, `tools: []`, the agent the helpers wrap - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — `ai.router` / `ai.fanOut` / supervisor `middleware` / `mockRouter` - [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) — budget / guardrail / semanticCache basics - [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) — `systemPrompt.fromFile` in context - [`@warlock.js/ai/pick-ai-provider/SKILL.md`](@warlock.js/ai/pick-ai-provider/SKILL.md) — `fallbackModel` wraps these adapters; cost-truth tokens ## approve-tool-calls `@warlock.js/ai/approve-tool-calls/SKILL.md` --- name: approve-tool-calls description: 'Gate an agent''s tool calls behind a human with `ai.human.approval(options)` (the `tool.before` approval-gate middleware) — ships in @warlock.js/ai core. Triggers: `ai.human.approval`, `humanApproval`, `HumanApprovalOptions`, `ApprovalRequest`, `ApprovalDecision`, `ApprovalHandler`, `InterruptPolicy`, `evaluatePolicy`, `ApprovalRejectedError`, `policy: { type: "allowlist" | "denylist" | "predicate" }`, decision `{ type: "approve" | "reject" | "edit" }`; ''human in the loop'', ''approve a tool call before it runs'', ''ask a human before the agent sends/charges/deletes'', ''pause before a dangerous tool'', ''let an operator edit the tool args'', ''reject a tool call with a reason the model can self-correct from''. Typical import `import { ai } from "@warlock.js/ai"`. Skip: persisting the request and resuming hours later out-of-process — `@warlock.js/ai/durable-resume/SKILL.md`; the agent/middleware/tool primitives themselves — `@warlock.js/ai`.' --- # Approve tool calls — the human-in-the-loop gate `ai.human.approval(options)` returns an `AgentMiddleware` with **one** hook — `tool.before` — that pauses *before a specific tool call* and routes it to a human. The human can **approve** (run the real tool unchanged), **reject** (the model sees a typed error and self-corrects), or **edit** (run the tool with replaced args). Every call the policy doesn't gate passes through untouched. `ai.human.*` ships natively on the shared `ai` object from `@warlock.js/ai` core — no extra import or registration step. The named `humanApproval` export is the same factory. ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const support = ai.agent({ model: openai.model({ name: "gpt-4o" }), tools: [refundCustomer, lookupOrder], middleware: [ ai.human.approval({ policy: { type: "allowlist", tools: ["refundCustomer"], tags: () => ["money"] }, // SSE / CLI handler resolves when the operator rules: handler: async (req) => ui.prompt(req), // → { type: "approve" } | { type: "reject", reason } | { type: "edit", args } }), ], }); await support.execute("Refund order #4821"); // pauses at refundCustomer, awaits the operator ``` ## The three decisions A handler turns an `ApprovalRequest` into an `ApprovalDecision` — a union discriminated by `type` (never `kind`): | `decision.type` | Effect | What the model sees next | |---|---|---| | `"approve"` | The real tool runs with the model's original args. | The tool's normal result. | | `"reject"` | Short-circuits an `ApprovalRejectedError` carrying `reason`. | `{ error }` on the next trip — it can self-correct. | | `"edit"` | The reviewer's `args` replace the model's; the real tool then runs. | The tool's result for the edited args. | ```ts type ApprovalDecision = | { type: "approve" } | { type: "reject"; reason: string } | { type: "edit"; args: unknown; reason?: string }; ``` An `edit` still goes through the tool's own Standard-Schema validation — if the replaced args don't fit the schema, the tool surfaces a validation error on `result.error` and the model self-corrects. No special-casing. ## The interrupt policy — which calls need a human `policy` decides *which* tool calls are gated. It's a union keyed on `type`: | `policy.type` | Gates a call when… | Tags | |---|---|---| | `"allowlist"` | the tool name **is** in `tools`. | optional `tags(toolName)` callback | | `"denylist"` | the tool name is **not** in `tools` (gate everything else). | optional `tags(toolName)` callback | | `"predicate"` | `requiresApproval(ctx)` returns a truthy value. | a returned `string[]` doubles as the tags | ```ts // Allowlist — only refunds need sign-off: { type: "allowlist", tools: ["refundCustomer"], tags: () => ["money"] } // Denylist — everything except read-only lookups needs sign-off: { type: "denylist", tools: ["lookupOrder", "searchCatalog"] } // Predicate — args-aware: only large refunds, tagged for the reviewer UI: { type: "predicate", requiresApproval: (ctx) => ctx.toolName === "refundCustomer" && (ctx.args as { amount: number }).amount > 100 ? ["money", "high-value"] : false, } ``` The predicate sees a read-only `PolicyContext` — `toolName`, `toolDescription`, `args` (the model's exact input), `agentName`, `tripIndex`, `sessionId`. Return `false` (or an empty array) to skip approval; `true` or a non-empty `string[]` to require it. The `string[]` becomes `request.context.tags`, surfaced verbatim to the reviewer so a UI can group or prioritize. Compose with `forTool(names, mw)` from `@warlock.js/ai` for static, name-based scoping and let `policy` be the dynamic, args-aware layer on top. `evaluatePolicy(policy, context)` is the exported, pure core if you want to reuse the gate decision outside the middleware (it never throws, does no IO, and returns `{ requiresApproval, tags? }`). ## The request a reviewer rules on For a gated call the middleware builds an `ApprovalRequest` and hands it to your `handler`: ```ts interface ApprovalRequest { interruptId: string; // stable id; durable mode keys the store on it toolName: string; toolDescription?: string; args: unknown; // the model's exact args context: { agentName: string; tripIndex: number; sessionId?: string; originalInput?: string; // the run's prompt (used by durable re-run) tags?: string[]; // from the policy match }; requestedAt: string; // ISO-8601 } ``` The handler runs in one of two modes that share this one signature: - **interactive** — return the decision (or a promise of it); the hook `await`s it in-process. The whole agent run stays on the stack — no store needed. This skill. - **durable** — persist the request and `throw` to suspend, resuming from another process later. See [`durable-resume/SKILL.md`](@warlock.js/ai/durable-resume/SKILL.md). ## It never throws out of the pipeline The middleware is a harness, not a detector — every outcome (skip, approve, reject, edit) returns normally. A `reject` does **not** throw out of `execute()`: it short-circuits a failed `ToolInvokeResult` carrying an `ApprovalRejectedError`, so the error rides `result.error` like every other `AIError` and `agent.execute()` still never throws. ```ts const result = await support.execute("Refund order #4821"); if (result.error instanceof ApprovalRejectedError) { logAudit(`${result.error.toolName} rejected: ${result.error.reason}`); } ``` Only a *handler bug* — a non-sentinel throw from your handler — propagates, and even then the agent dispatch funnels it onto `result.error` rather than crashing the run. The gate never swallows a bug into a silent approval. ## Edge cases - **Duplicate middleware name.** Middleware names are validated unique per agent. The default name is `"human-approval"`, so two approval middlewares on one agent need distinct `name`s. - **Silent tools.** A `silent`-mode tool's result isn't fed back to the model, but approval still runs (we gate *before* dispatch). A `reject` on a silent tool writes a tool message that's harmless but unread. - **Abort during an interactive await.** `ctx.signal` is in scope; honor it in a long-running handler so a cancelled run rejects rather than hanging. ## See also - [`@warlock.js/ai/durable-resume/SKILL.md`](@warlock.js/ai/durable-resume/SKILL.md) — persist the request, resume out-of-process hours later via `ai.human.resume(...)` and the `InterruptStore`. - `@warlock.js/ai` — the `ai.agent(...)`, `AgentMiddleware`, `tool.before`, and `ToolInvokeResult` primitives this gate wraps. ## attach-ai-middleware `@warlock.js/ai/attach-ai-middleware/SKILL.md` --- name: attach-ai-middleware description: 'Wire agent middleware — ai.middleware.budget (token / USD caps + SLO/cost contract w/ maxLatencyMs + onViolation fallback), ai.middleware.guardrail (pre / post content checks), ai.middleware.semanticCache (exact + vector cache), supervisor-level middleware, plus authoring custom hooks (execute / trip / tool). Triggers: `ai.middleware.budget`, `ai.middleware.guardrail`, `ai.middleware.semanticCache`, `ai.middleware.compose`, `ai.middleware.forTool`, `AgentMiddleware`, `BudgetExceededError`, `GuardrailViolationError`, `BudgetContract`, `maxLatencyMs`, `onViolation`, `readBudgetFallbackSignal`, `supervisor middleware`, `SemanticCacheOptions`, `SemanticCacheScope`; ''cap token cost'', ''SLO budget'', ''block pii in prompts'', ''semantic cache before LLM'', ''supervisor-level middleware'', ''write custom hook'', ''isolate semantic cache per session/tenant''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent lifecycle — `@warlock.js/ai/run-ai-agent/SKILL.md`; cache drivers — `@warlock.js/ai/persist-ai-data/SKILL.md`; competing libs `langchain` callbacks.' --- # Middleware — agent-level pipeline Cross-cutting concerns wrapped around an agent run at three agent-level granularities (`execute`, `trip`, `tool`), plus a `supervisor` level that wraps a whole supervisor run (see below). One middleware = one object. Ships with `budget`, `guardrail`, and `semanticCache` built-ins. ## Install order at a glance ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; import { cache } from "@warlock.js/cache"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); ai.config({ defaultStore: cache.driver("redis", { client: redisClient }) }); const myAgent = ai.agent({ model: openai.model({ name: "gpt-4o" }), middleware: [ ai.middleware.semanticCache({ embedder: openai.embedder({ name: "text-embedding-3-small" }), threshold: 0.95, }), ai.middleware.budget({ maxTokens: 50_000 }), ai.middleware.guardrail({ inputCheck: async (text) => text.match(/\bSSN\b/) ? { ok: false, reason: "pii" } : { ok: true }, }), ], }); ``` **Canonical order: `[cache, budget, guardrail, observability]`** — see "Ordering invariants" below. ## `ai.middleware.budget(options)` Cumulative token / USD cap across all trips of one execution. ```ts ai.middleware.budget({ maxTokens: 50_000, maxCostUSD: 0.5, pricing: { "gpt-4o": { inputPer1K: 0.005, outputPer1K: 0.015 } }, onExceeded: "abort", // or "warn" }); ``` Breach → `BudgetExceededError` on `result.error`. Inspect `error.unit` (`"tokens" | "usd"`), `error.limit`, `error.actual`. Warn mode logs and continues — useful for measuring before enforcing. USD only fires when both `maxCostUSD` AND a matching `pricing[modelName]` entry exist. ### SLO / cost contract — `budget({ contract })` On top of the legacy caps, declare a run-level SLO as data with one global reaction. Adds a wall-clock `maxLatencyMs` dimension: ```ts ai.middleware.budget({ pricing: { "gpt-4o": { inputPer1K: 0.005, outputPer1K: 0.015 } }, contract: { maxTokens: 40_000, maxCostUSD: 0.05, maxLatencyMs: 8_000, // wall-clock, first execute.before → each trip.after onViolation: "fallback", // "abort" (default) throws; "fallback" records a signal + continues fallback: (violation) => routeToCheaperModel(violation.dimension), }, }); ``` Every clause optional (no caps = inert). `"fallback"` can't itself swap models — it records a typed `BudgetContractViolation` and fires `fallback`; an outer layer reads it via `readBudgetFallbackSignal(ctx.state)` and degrades the next run. A latency breach has no `BudgetUnit` — read its numbers from the thrown error's `context.dimension`. Full coverage in [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md). ## `ai.middleware.guardrail(options)` Pre / post content checks. ```ts ai.middleware.guardrail({ inputCheck: async (text, ctx) => text.includes("forbidden") ? { ok: false, reason: "policy-1" } : { ok: true }, outputCheck: async (text) => text.length > 10_000 ? { ok: false, reason: "too-long" } : { ok: true }, name: "pii-guardrail", }); ``` Rejection → `GuardrailViolationError` with `phase: "input" | "output"` and the configured `reason`. Output checks fire BEFORE tool dispatch — a rejected response means the tools it requested are never invoked. Checks run on every trip (including tool follow-ups and repair attempts). Gate only the first trip via `ctx.tripIndex === 0`. ## `ai.middleware.semanticCache(options)` Two-tier cache — exact-match key first, vector similarity second. Delegates to any vector-capable `CacheDriver`. ```ts ai.middleware.semanticCache({ embedder: openai.embedder({ name: "text-embedding-3-small" }), // store optional — falls back to ai.config({ defaultStore }) store: cache.driver("pg", { client: pgPool, vector: { dimensions: 1536, index: "hnsw" }, }), threshold: 0.95, ttlMs: 60 * 60 * 1000, namespace: "support-faq", // scope: "session" (default) — see below }); ``` **Driver requirements.** Must support `similar()` — `pg` (with `vector` config), `redis` (with RediSearch), or memory drivers for dev. Without similarity → `CacheUnsupportedError` at first vector op. **How it works.** - **Exact-match** — FNV hash over the message list. `store.get(hash)` returns an instant hit. - **Vector-match** — embeds the prompt, calls `store.similar(vector, { topK: 1, threshold })`. Driver uses its native ANN index. - **Hits** return a synthetic `ModelResponse` with `usage: { input: 0, output: 0, total: 0 }`. - **Writes** happen at `trip.after` on miss. - **Trip-zero only** — only first-trip responses are cached. Tool-using loops never serve cached tool-call responses (would infinite-loop). - **Never use memory drivers in production** — linear scan per query. ### Session-scoped by default — `scope` (4.15.0) A `semanticCache` is normally built once at app boot and shared by every end user, and a hit is returned as the model's answer with **no LLM call in between** — so without isolation, user B's merely-*similar* prompt could be served user A's cached answer, personal context included. `SemanticCacheOptions.scope` (default `"session"`) keys every entry off the run's `AgentExecuteOptions.sessionId` (`"session:"`) and re-checks it as exact equality on read — the key alone never authorizes a hit. ```ts ai.middleware.semanticCache({ embedder, threshold: 0.95, scope: "shared" }); // opt back into one shared pool ai.middleware.semanticCache({ embedder, threshold: 0.95, scope: (ctx) => tenantIdFrom(ctx) }); // custom boundary ``` - **`"session"`** (default) — isolated per `sessionId`; a run made *without* a `sessionId` shares one unscoped pool (unchanged behavior for those calls). Thread `sessionId` through `agent.execute()` to get the isolation — composite primitives (supervisor, orchestrator) already forward their own. - **`"shared"`** — one pool for every caller, regardless of session — the pre-4.15.0 behavior. The explicit opt-in for genuinely public Q&A (docs bot, FAQ) where cross-user hit rate is the point and no response can carry a caller's private context. - **`(context) => key | undefined`** — derive your own boundary, e.g. per tenant. Returning `undefined` falls back to the unscoped pool. Entries written before the upgrade are unscoped and are only read by unscoped (or `"shared"`) runs. The vector lookup overscans before filtering (mirroring the memory tiers) so a noisy foreign scope can't occupy the top-`k` and mask a caller's own hit. ## Writing your own middleware One object. Any subset of three hook maps. ```ts import type { AgentMiddleware } from "@warlock.js/ai"; const latencyLogger: AgentMiddleware = { name: "latency-logger", execute: { before(ctx) { ctx.state.set("latency.start", performance.now()); }, after(ctx, result) { const start = ctx.state.get("latency.start") as number; console.log(`agent ${ctx.agent.name} finished in ${performance.now() - start}ms`); }, }, trip: { before(ctx) { ctx.state.set(`latency.trip.${ctx.tripIndex}.start`, performance.now()); }, after(ctx) { const start = ctx.state.get(`latency.trip.${ctx.tripIndex}.start`) as number; console.log(` trip ${ctx.tripIndex}: ${performance.now() - start}ms`); }, }, }; ``` ### Rules - **Never close over mutable state.** Use `ctx.state` — fresh per `execute()` call. - **Abort with a typed `AIError` subclass.** Never `throw new Error(...)`. - **Short-circuit by returning from `before`.** Return the level's result type — the pipeline skips the real work and outer `after` hooks still run on your synthetic value. - **`onError` is opt-in recovery.** Return a value to recover; return `void` to let the error propagate. - **`log: false`** suppresses framework debug emission for that middleware (the middleware itself still runs). ## Ordering invariants — read before shipping 1. **Cache MUST be outermost when guardrails are present.** Guardrail `trip.after` throws to reject bad output — but `after` hooks run bottom-up. If guardrail is outside the cache, rejection fires AFTER the cache has written the bad response. Canonical order `[cache, budget, guardrail]` keeps rejected outputs out of the cache. 2. **Budget before guardrails.** Guardrails may call classifiers with their own token costs. 3. **Observability last.** It should see the final decision every other middleware made. ## Helpers ### `ai.middleware.compose(...sources)` Flatten multiple sources into one ordered array. No sorting, no dedup. ```ts ai.agent({ model, middleware: ai.middleware.compose(standardStack, toolRules, auditMiddleware), }); ``` ### `ai.middleware.forTool(name | names, middleware)` Scope `tool.*` hooks to specific tool names. `execute` and `trip` hooks pass through. ```ts const scoped = ai.middleware.forTool(["paid_api", "expensive_db"], toolRateLimit({ maxCalls: 5 })); ``` ## Caveats - **`tool.onError` is almost-never-useful.** `ToolContract.invoke()` never throws — errors are captured into `result.error`. `tool.onError` only fires when another middleware's `tool.before`/`tool.after` itself throws. For "the tool itself failed," branch on `result.error` in a `tool.after`. - **Middleware does NOT observe unregistered tool calls.** When the model asks for a tool the agent wasn't configured with, the pipeline is bypassed and a failed `ToolCall` is recorded directly. - **`name` must be unique** across an agent's middleware array. - **Middleware state does NOT cross `agent.execute()` boundaries.** One execute → one fresh `ctx.state`. ## Supervisor-level middleware `ai.supervisor({ middleware: [...] })` fires each middleware's optional `supervisor` hook map (`before` / `after` / `onError`) ONCE around the whole `execute()` / `stream()` / `resume()` run — the supervisor-level peer of an agent's `execute`-level middleware: ```ts ai.supervisor({ name: "support", router, intents, middleware: [auditTrail] }); ``` Same onion semantics: `before` top-down (return a `SupervisorResult` to short-circuit, throw to abort), `after` / `onError` bottom-up. A middleware WITHOUT a `supervisor` hook map is skipped — so the SAME builtin objects (budget, guardrail, …) can be registered on agents AND on the supervisor, each declaring whichever level applies. Each needs a unique `name`. See [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md). ## Workflow + middleware — what works today - Inside a workflow step with `agent: myAgent` — the agent's own middleware fires normally. - `workflow.asTool()` called from an agent — the calling agent's `tool`-level middleware wraps the workflow. - Step-level / workflow-level middleware does NOT exist yet (supervisor-level DOES — see above). ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — agent lifecycle the middleware wraps - [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — `defaultStore` for semantic cache - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `BudgetExceededError` / `GuardrailViolationError` ## define-ai-tool `@warlock.js/ai/define-ai-tool/SKILL.md` --- name: define-ai-tool description: 'Define tools with ai.tool({...}) — typed validated async functions the model can call. Covers name / description / action / mode (feedback / silent) / input / execute, `ctx.artifacts` side-channel, `ToolExecutionError`. Triggers: `ai.tool`, `ToolContract`, `ToolContext`, `ToolCall`, `ToolExecutionError`, `artifactsSchema`, `mode: "silent"`, `workflow.asTool`; ''define a tool'', ''wire tool into agent'', ''tool input validation'', ''side-channel artifacts''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent loop — `@warlock.js/ai/run-ai-agent/SKILL.md`; supervisor artifacts — `@warlock.js/ai/run-supervisor/SKILL.md`; competing libs `langchain` tools, raw `openai` function-calling.' --- # `ai.tool()` — typed tool factory Tools are async functions the model can call by name during a trip loop. Define one with `ai.tool()`, pass it in `agent({ tools: [...] })`, and the agent handles dispatch, input validation, and error surfacing automatically. ## Factory shape ```ts ai.tool({ name: string, // stable identifier description: string, // sent to the model version?: string, // mirrored onto tool reports action?: string | ((input: TInput) => string), // UI label for streaming UX mode?: "feedback" | "silent", // result feedback control input: StandardSchemaV1, // validated before execute execute: (input: TInput, ctx?: ToolContext) => Promise, }); ``` Returns a `ToolContract`. One tool can be attached to many agents. ## `description` vs `action` Two roles, two fields: - **`description`** — what the LLM reads when deciding whether to call this tool. - **`action`** — present-progressive UI string surfaced to humans on `agent.tool.calling` / `agent.tool.called` events. ```ts ai.tool({ name: "search_catalog", description: "Search the product catalog. Returns matching products with SKU, name, price.", action: ({ query }) => `Searching the catalog for "${query}"`, input: v.object({ query: v.string() }), execute: async ({ query }) => searchProducts(query), }); ``` Two forms supported: static string or function. Function form runs after input validation; throws are swallowed (UI strings aren't worth aborting LLM dispatch over). ## Schema via Standard Schema V1 Input is typed as `StandardSchemaV1`. Recommended: `@warlock.js/seal`. Zod / Valibot / hand-rolled all interop. ```ts import { v } from "@warlock.js/seal"; const searchTool = ai.tool({ name: "search", description: "Search the docs index", input: v.object({ query: v.string(), limit: v.number().optional(), }), execute: async ({ query, limit }) => fetchDocs(query, limit ?? 10), }); ``` ## Input validation is automatic The agent calls `input["~standard"].validate(rawArgs)` before invoking `execute`. Validation failures **do not throw** — the failure is recorded on the trip's `ToolCall.error` and fed back to the model on the next trip as a tool error message. The model gets a chance to correct and retry within the bounded `maxTrips` loop. ## What gets returned to the model Whatever your `execute` resolves with is `JSON.stringify`'d and sent back as the next trip's `tool` message. Strings pass through unchanged. Throw (or return a rejected promise) to signal failure — the agent records the error on `ToolCall.error` and tells the model. ## `mode` — feedback vs silent Default `"feedback"`. - **`mode: "feedback"`** (default) — standard round-trip. Result feeds back into next trip; the model reads it and replies. Use for tools whose output the model needs to narrate: `search_catalog`, `search_knowledge_base`, `ask_questions`. - **`mode: "silent"`** — fire-and-forget. Result NOT fed back to the model. When EVERY tool call in a single generation is silent, the agent loop terminates after dispatch. Use for pure side-effect tools: `update_state`, `set_locale`, telemetry pings. ```ts ai.tool({ name: "update_state", description: "Persist customer slot-fill across turns.", mode: "silent", input: v.object({ preferences: v.array(v.string()).optional() }), execute: async (patch, ctx) => { ctx.artifacts.stateUpdate = patch; return { ok: true }; // model never sees this }, }); ``` **All-silent rule.** The loop terminates only when EVERY tool call this trip is silent. Silent + feedback in the same generation → loop continues (the feedback tool still round-trips, the silent one piggybacks). **Constraints for silent tools.** MUST be cheap + fast (HTTP request still open until dispatch resolves), should be idempotent (no surface to communicate failure to the model), side-effect-only. ## Tool context — `ctx.artifacts` side-channel `execute` accepts an optional **second argument** — a `ToolContext` with a mutable `artifacts` bag and the dispatch's `signal`. Use it to capture system-only data (renderable blocks, citations, files, telemetry, soft signals) that the LLM should NOT see. ```ts ai.tool({ name: "search_catalog", input: v.object({ query: v.string() }), execute: async (input, ctx) => { const items = await searchItems(input.query); // Side-channel — never reaches the LLM. ctx.artifacts.blocks ??= []; ctx.artifacts.blocks.push({ type: "items", itemIds: items.map(i => i.id) }); // LLM-visible — what the agent reasons over. return { total: items.length }; }, }); ``` Under a supervisor: bag starts empty per iteration, accumulates writes from all tool calls, merges into state at iteration end (auto-spread by default; `finalizeArtifacts` for concat / dedupe). See [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md). Standalone (no supervisor): framework supplies `{ artifacts: {} }`. Mutations are harmless no-ops. ## Type contract for artifacts The supervisor declares an `artifactsSchema`; tools registered to it inherit typed `ctx.artifacts.*`. Standalone tools fall back to `Record`. ```ts ai.supervisor({ artifactsSchema: v.object({ blocks: v.array(blockSchema).optional(), citations: v.array(citationSchema).optional(), }), // tools see ctx.artifacts typed as { blocks?, citations? } }); ``` ## Error categorization `invoke()` never throws — failures surface on the returned `error` field, and the agent records them on the dispatch's `ToolCall.error`. The error class depends on what failed: - **Input schema rejected the model's args** → `SchemaValidationError` (`code: "SCHEMA_VALIDATION_FAILED"`), `issues` preserved. NOT wrapped in `ToolExecutionError`. - **Schema's own `validate()` threw** → `SchemaValidationError` wrapping the cause. - **Your `execute()` threw** → `ToolExecutionError` (`code: "TOOL_EXEC_FAILED"`, category `tool`) with `toolName`, and the thrown value on `error.cause`. `ToolExecutionError` carries `toolName` always; `tripIndex` is stamped by the agent that dispatched it. The validation failure is fed back to the model on the next trip so it can correct within the `maxTrips` loop. See [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md). ## Inspecting tool calls ```ts const result = await myAgent.execute("Pick a city and tell me the weather."); const toolCalls = result.report.children.filter((c) => c.type === "tool"); for (const call of toolCalls) { console.log(call.tripIndex, call.name, call.input, call.output, call.duration); } ``` Tool dispatches are child `BaseReport` nodes on `report.children` (not a separate `report.toolCalls` field) — filter by `c.type === "tool"`. Each `ToolCall` is a `BaseReport & { type: "tool", tripIndex, input, output?, error? }`, so it carries `name` / `startedAt` / `endedAt` / `duration` from the report base. ## Events - `agent.tool.calling` — `{ tool, input, tripIndex }` - `agent.tool.called` — `ToolCall & { tool }` (full record) - `agent.tool.failed` — `{ tool, input, error, tripIndex }` Subscribe at factory / instance / per-call. ## Pattern — workflow as a tool ```ts const wrapped = myWorkflow.asTool({ description: "Run the catalog ingestion workflow", inputSchema: v.object({ url: v.string() }), }); const agent = ai.agent({ model, tools: [wrapped] }); ``` Workflow errors surface as `ToolExecutionError` with `cause` pointing at the original `WorkflowError` subclass. ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — how tools plug into the trip loop - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — error hierarchy - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — artifacts under a supervisor - [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — `workflow.asTool()` composition ## detect-and-redact-pii `@warlock.js/ai/detect-and-redact-pii/SKILL.md` --- name: detect-and-redact-pii description: 'Detect and redact PII (and run model-graded moderation) with @warlock.js/ai-guard detectors — `ai.guardrail.pii(...)` and the optional `ai.guardrail.moderation(...)` peer. Triggers: `ai.guardrail.pii`, `piiDetector`, `PiiDetectorOptions`, `PiiCategory`, `mask`, `{label}`, `dictionary`, `onMatch`, `ai.guardrail.moderation`, `openAiModeration`, `OpenAiModerationOptions`, `blockOn`, `omni-moderation-latest`; ''redact PII from model output'', ''mask SSN / credit card / email / phone / IP'', ''stop PII leaking into a tool call'', ''scrub sensitive data'', ''add OpenAI moderation'', ''block violent / self-harm content''; typical import `import "@warlock.js/ai-guard"` (registers `ai.guardrail.pii` / `.moderation`) or `import { pii, moderation } from "@warlock.js/ai-guard"`. Skip: composing the guard / wiring it into an agent — `@warlock.js/ai-guard/guard-input-output/SKILL.md`; routing a block to a human — `@warlock.js/ai-guard/escalate-block-to-human/SKILL.md`.' --- # Detect and redact PII (and moderate) `ai.guardrail.pii(...)` is a **zero-dependency** detector — regex + exact-string matching, no runtime peer. Pass it into any phase array of `ai.guardrail({ ... })`. ```ts import { ai } from "@warlock.js/ai"; import "@warlock.js/ai-guard"; const policy = ai.guardrail({ output: [ai.guardrail.pii({ onMatch: "redact", mask: "[REDACTED:{label}]" })], }); ``` ## Categories — `detect` Scans for these `PiiCategory` values; `detect` narrows the set (default: all). Each pattern is **linear** (anchored, no nested quantifiers) — safe against catastrophic backtracking. | Category | Matches | |---|---| | `ssn` | US Social Security numbers | | `email` | email addresses | | `phone` | phone numbers | | `credit-card` | credit-card numbers | | `ipv4` | IPv4 addresses | ```ts ai.guardrail.pii({ detect: ["ssn", "credit-card"] }); // scan only these two ``` Add `dictionary` for extra exact-string terms (internal codenames, customer IDs) treated as PII alongside the built-in regexes: ```ts ai.guardrail.pii({ dictionary: ["PROJECT-ORION", "ACME-INTERNAL"] }); ``` ## Action — `onMatch` `onMatch` is `"redact" | "block" | "flag"`, default **`"redact"`**: - **`redact`** — replace each match with the `mask` and continue (output phase only — see below). - **`block`** — reject the trip / tool call with a `GuardrailViolationError`. - **`flag`** — allow but record the matches into `ctx.state` for a downstream observer. ## The `mask` template On `redact`, each match is replaced by `mask`. The `{label}` token is substituted with the matched category, so a redacted SSN becomes `[REDACTED:ssn]`: ```ts ai.guardrail.pii({ onMatch: "redact", mask: "[REDACTED:{label}]" }); // "My SSN is 123-45-6789" -> "My SSN is [REDACTED:ssn]" ``` Omit `mask` to use the default fixed placeholder. ## Where redaction actually applies Redaction only rewrites-and-continues where the pipeline seam supports it: - **Output (`output: [...]`)** — works. `trip.after` returns a replacement `ModelResponse` with the scrubbed `content`. This is the primary PII-redaction use case. - **Input (`input: [...]`)** — a `redact` verdict **downgrades to `block`**. The core `trip.before` hook can only short-circuit, not rewrite-and-continue, so the un-redacted prompt can't be threaded back. - **Tool (`tool: [...]`)** — a `redact` verdict **downgrades to `block`** (`reason: "tool-arg-redaction-unsupported"`), because silently rewriting tool arguments changes the call's side-effects. So: **redact on output, block on input/tool.** ```ts const policy = ai.guardrail({ output: [ai.guardrail.pii({ onMatch: "redact", mask: "[REDACTED:{label}]" })], // scrub the answer tool: [ai.guardrail.pii({ onMatch: "block" })], // refuse to leak into tools toolNames: ["send_email"], }); ``` ## Optional moderation peer — `ai.guardrail.moderation` For model-graded content (violence, self-harm, hate) beyond regex, the optional `moderation` detector calls OpenAI's moderation endpoint. The `openai` SDK is an **optional lazy peer** — importing `@warlock.js/ai-guard` never forces it to resolve; the detector throws a curated install string on first `check()` when the peer is absent (mirrors ai-panoptic's lazy Langfuse exporter). ```ts const policy = ai.guardrail({ output: [ ai.guardrail.moderation({ blockOn: ["violence", "self-harm"] }), ], }); ``` - `blockOn` — categories that escalate to `block`; every other flagged category produces a `flag`. Omit to `flag` on any category. - `model` — defaults to `"omni-moderation-latest"`. - `apiKey` — defaults to `OPENAI_API_KEY`. - `client` — pass a pre-built OpenAI-compatible client to bypass the lazy import entirely (the bring-your-own-client / test escape hatch). Install the peer only when you use this detector: ```bash npm install openai ``` ## See also - [`@warlock.js/ai-guard/guard-input-output/SKILL.md`](@warlock.js/ai-guard/guard-input-output/SKILL.md) — composing the guard, the verdict model, phases, and `toolNames` scoping. - [`@warlock.js/ai-guard/escalate-block-to-human/SKILL.md`](@warlock.js/ai-guard/escalate-block-to-human/SKILL.md) — escalating a hard `block` to a human-review surface. ## durable-agent-runs `@warlock.js/ai/durable-agent-runs/SKILL.md` --- name: durable-agent-runs description: 'Mid-run crash-resume for agents AND planners — opt in with durable: { store, deleteOnComplete? } on the config, pass a stable runId to execute(), and call agent.resume(runId) / planner.resume(runId) after a crash to continue from the last settled trip / plan node. Reuses the ai.snapshot.{memory,pg,redis} stores; checkpoints per-trip (agent) / per-node (planner); completed trips + nodes never re-run their tools and usage is never double-counted; a drifted definition throws AgentDriftError / PlannerDriftError (bypass with { force: true }). Triggers: `durable`, `agent.resume`, `planner.resume`, `resume(runId)`, `runId`, `AgentSnapshot`, `PlannerSnapshot`, `AgentSnapshotStatus`, `PlannerSnapshotStatus`, `AgentDriftError`, `PlannerDriftError`, `computeAgentSignature`, `agent.signature`, `deleteOnComplete`, `defaultSnapshotStore`, `ai.snapshot.pg`, `ai.snapshot.memory`, `SnapshotStore`, `force: true`; ''resume an agent after a crash'', ''durable agent run'', ''continue a planner from where it crashed'', ''checkpoint agent state'', ''idempotent tool re-run on resume'', ''signature drift on resume''; typical import `import { ai } from "@warlock.js/ai"`. Skip: durable human-in-the-loop approval resume (ai.human.resume of a PendingInterrupt) — `@warlock.js/ai/durable-resume/SKILL.md`; supervisor/workflow iterate-mid-turn snapshot resume + the store contracts themselves — `@warlock.js/ai/manage-ai-stores/SKILL.md`; competing libs `temporal`, `inngest`, `restate`.' --- # Durable agent + planner runs — resume from the last checkpoint Opt-in mid-run crash-resume for the two long-running primitives. Turn it on, give the run a stable `runId`, and after a process crash `resume(runId)` re-hydrates the persisted state and continues from where it stopped — never re-issuing a settled trip's model call or re-invoking a completed node's capability. > **Not the same as [[durable-resume]].** That skill is `ai.human.resume(interruptId, decision)` — resuming a **gated tool call** hours later after a human rules (a `PendingInterrupt` in an `InterruptStore`). *This* skill is **crash-resume of an in-flight run** (an `AgentSnapshot` / `PlannerSnapshot` in a `SnapshotStore`): the process died mid-run, you restart, and continue the same trip / plan. Different trigger (a crash, not a human), different store, different verb (`agent.resume` / `planner.resume`, not `ai.human.resume`). ## Opt in — `durable` on the config ```ts import { ai } from "@warlock.js/ai"; const writer = ai.agent({ name: "writer", model, tools: [searchTool, draftTool], durable: { store: ai.snapshot.pg({ client: pgPool }), // reuses the ai.snapshot.* stores deleteOnComplete: false, // default — keep for the completed-run short-circuit + audit }, }); ``` `durable` shape (identical on the agent and planner config): - **`store?`** — a `SnapshotStore`. Falls back to `ai.config({ defaultSnapshotStore })`. When neither resolves, snapshot writes **silently skip** and `resume()` throws. - **`deleteOnComplete?`** — drop the snapshot once the run completes successfully. Default `false`. **Absent `durable` ⇒ zero behavior change** — the loop starts at trip 0 / the first node, never writes a snapshot, and runs byte-for-byte as before. ## Run with a stable `runId`, then resume The `runId` is the store key. Pass a stable one to `execute()` (or read the generated one off `result.report.runId`) so a later `resume()` can find the snapshot: ```ts const result = await writer.execute("research X", { runId: "run-42" }); // ...process crashes mid-run, restarts... const recovered = await writer.resume("run-42"); // continues from the next unsettled trip; `recovered.report.status === "completed"` ``` Planners are the mirror image — `durable` on the config, `runId` on `execute(goal)`, `planner.resume(runId)`: ```ts const research = ai.planner({ name: "research-assistant", model, capabilities: [{ name: "search", executable: searchAgent }, { name: "write", executable: writerAgent }], durable: { store: ai.snapshot.pg({ client: pgPool }) }, }); const first = await research.execute("compare A vs B", { runId: "plan-7" }); // ...crash... const done = await research.resume("plan-7"); ``` ## Checkpoint granularity | Primitive | Written | Contains | Resume continues at | |---|---|---|---| | **agent** | after every settled **trip** (`runTrip` end) | `messages`, `trips`, `toolCalls`, `usage`, resolved `systemPrompt` / `responseSchema`, `signature`, `status` | `trips.length` (the next trip index) | | **planner** | after every settled **plan node** (`executeStep` end) | the frozen `plan`, `executedSteps` ledger, `usage`, child `children` reports, `replanCount`, `signature`, `status` | the unfinished frontier (from `executedSteps`) | The write happens only where the persisted arrays are mutually consistent — for the agent, after every tool a trip requested has been dispatched and its result appended. A crash **mid-trip** loses only that in-flight trip (never checkpointed), which the resume re-issues cleanly. The planner **never re-calls the planning LLM** on resume — the plan is frozen on the first run; re-asking would burn tokens and risk a plan that no longer matches the ledger. Every field on both snapshots is JSON-serializable, so they round-trip through any `ai.snapshot.{memory,pg,redis}` backend verbatim. ## Idempotency — what does and doesn't re-run ```ts // Completed run: resume is a no-op that re-returns the stored result. const again = await writer.resume("run-42"); // runs nothing when status === "completed" ``` - **Completed trips / nodes never re-run their tools.** On agent resume, `trips.length` is the starting trip index — earlier trips' model calls are not replayed and their tool dispatches are not re-invoked. On planner resume, a completed node's capability dispatch is skipped (the sequential skip-guard / DAG re-seed derive the completed set from `executedSteps`). - **Usage is never double-counted.** The running `usage` total is restored from the snapshot; only the newly-executed trips / nodes add to it. - **Caveat — a crash MID-trip re-runs that trip's tools.** The in-flight trip was never checkpointed, so on resume its tools fire again. **Side-effectful tools (charging a card, sending an email) must be idempotent** — the same caller-responsibility boundary the supervisor and workflow primitives document. Guard them with your own dedupe key (e.g. `${runId}:${toolCallId}`). ## Drift — definition changed since the snapshot Every agent / planner carries a structural `signature` (`agent.signature` — computed at factory time by `computeAgentSignature`), stamped on each snapshot. `resume()` compares the stored signature against the current definition; a mismatch throws before executing anything: - **agent** covers: model name + provider, sorted tool names, `maxTrips`, whether a default `output` schema is set, `version`. It does **not** cover system-prompt text, middleware, per-event handlers, placeholders, or `modelOptions` — runtime knobs that don't change a resumable run's shape. - **planner** covers: name + ordered capability names. A mid-run **re-plan is NOT drift** (the plan changed, not the definition); `replanCount` is persisted so the replan budget survives a resume. ```ts import { AgentDriftError } from "@warlock.js/ai"; try { await writer.resume("run-42"); } catch (error) { if (error instanceof AgentDriftError) { // The definition changed (a tool was added, the model swapped). Either roll the // definition back, or — only when you've verified the change is snapshot-safe: await writer.resume("run-42", { force: true }); // bypasses the drift check } } ``` `{ force: true }` is the escape hatch (mirror `PlannerDriftError` for planners). `resume()` also throws `AgentExecutionError` / `PlannerFailedError` when no store is configured or no snapshot exists for the `runId`. ## Pattern — a boot-drain resume loop On restart, resume every run the store still has in flight. Snapshots carry a `status` (`"running" | "completed" | "cancelled" | "failed"`), so you only resume the live ones: ```ts const store = ai.snapshot.pg({ client: pgPool }); const runIds = (await store.list?.()) ?? []; for (const runId of runIds) { const snapshot = await store.load(runId); if (snapshot?.status === "running") { await writer.resume(runId); // completed/failed snapshots short-circuit or re-throw — skip them } } ``` Pair `deleteOnComplete: true` with this loop when you don't need the completed-run audit trail — the store then holds only genuinely-unfinished runs, so the drain never touches settled ones. ## Cost + testing - **Checkpointing cost is one store write per settled trip / node** — a `JSONB` upsert on `pg`, an in-process `Map` set on `memory`. A failed checkpoint is surfaced via logs, not thrown: it loses resume-ability from that point but never breaks an otherwise-healthy run. - **Resume saves the tokens of every settled trip / node** — their model calls are not replayed. A completed-run resume spends nothing (it rebuilds the result from the snapshot). The planning LLM is never re-called on planner resume. - **Test with `ai.snapshot.memory()`.** Drive `execute(input, { runId })` against a flaky model that throws once, assert the tool spy was called once, flip the failure off, `resume(runId)`, and assert (a) `status === "completed"`, (b) the tool spy count is unchanged (no re-invoke), and (c) `usage.total` counts each trip's tokens exactly once. Drift is testable by mutating the definition (add a tool) between `execute` and `resume` and asserting `AgentDriftError` — then `{ force: true }` proceeds. ## See also - [[handle-ai-errors]] — the typed `AgentDriftError` / `PlannerDriftError` / `AgentExecutionError` / `PlannerFailedError` and how `result.error` surfaces a failed run. - [[manage-ai-stores]] — the `ai.snapshot.{memory,pg,redis}()` factories, the `SnapshotStore` contract, dev-owned `pg` / `redis` clients, and never-auto-migrated `schema()`. - [[persist-ai-data]] — supervisor / workflow snapshot resume (the sibling `iterate`-style durability) and the SnapshotStore migration notes. - [[durable-resume]] — the OTHER resume: `ai.human.resume` of a gated tool call (human-in-the-loop), not a crash. ## durable-resume `@warlock.js/ai/durable-resume/SKILL.md` --- name: durable-resume description: 'Persist a gated tool call and resume it from another process hours later — ships in @warlock.js/ai core: `ai.human.resume(interruptId, decision, options)`, the `InterruptStore` (`ai.human.interrupt.{memory,pg,redis}()`), `PendingInterrupt`, and the `InterruptSuspendedError` suspend sentinel. Triggers: `ai.human.resume`, `resume(interruptId, decision)`, `InterruptStore`, `ai.human.interrupt.memory`, `ai.human.interrupt.pg`, `ai.human.interrupt.redis`, `interruptMemory`, `interruptPg`, `interruptRedis`, `PendingInterrupt`, `InterruptSuspendedError`, `ResumeOptions`, `ResumeResult`, `PgClientLike`, `RedisClientLike`; ''approve hours later from a webhook'', ''persist the approval request and resume in another process'', ''durable human-in-the-loop'', ''store the interrupt in Postgres/Redis'', ''re-run the agent turn once the human approves''. Typical import `import { ai, InterruptSuspendedError } from "@warlock.js/ai"`. Skip: the in-process await gate and the policy/decision shapes — `@warlock.js/ai/approve-tool-calls/SKILL.md`.' --- # Durable resume — persist the interrupt, approve from another process Interactive approval `await`s the operator in-process. **Durable** approval is for when the reviewer rules out-of-band — a Slack button, a webhook, hours later, in a different process. The flow: the handler **persists** the request to an `InterruptStore` and **throws** `InterruptSuspendedError` to suspend the run; the caller surfaces the `interruptId`; later, `ai.human.resume(interruptId, decision, { store })` applies the ruling. > **v1 durable resume re-runs the turn** with the decision pre-seeded — it does **not** rehydrate an in-flight supervisor mid-call (that's the deferred v2 lift). Re-running is idempotent because the prompt and the seeded decision fully determine the gated call's outcome. ## Process A — suspend and surface the id ```ts import { ai, InterruptSuspendedError } from "@warlock.js/ai"; const store = ai.human.interrupt.memory(); // swap for pg / redis in production const agent = ai.agent({ model, tools: [deleteAccount], middleware: [ ai.human.approval({ policy: { type: "predicate", requiresApproval: (c) => c.toolName === "deleteAccount" }, store, handler: async (req) => { // 1. persist the pending interrupt await store.save({ interruptId: req.interruptId, request: req, status: "pending", savedAt: new Date().toISOString(), }); // 2. notify the reviewer out-of-band await slack.postApproval(req); // 3. suspend the run — the middleware recognizes its OWN sentinel throw new InterruptSuspendedError("Awaiting human approval", { interruptId: req.interruptId, }); }, }), ], }); const result = await agent.execute("Delete account #88"); // execute() never throws — the suspend rides result.error: if (result.error instanceof InterruptSuspendedError) { return { status: "awaiting-approval", interruptId: result.error.interruptId }; } ``` The middleware catches the **sentinel** (`instanceof InterruptSuspendedError`) and short-circuits a failed `ToolInvokeResult` carrying it, so `error.interruptId` is on `result.error`. Hand that id to the reviewer. ## Process B — resume hours later ```ts import { ai } from "@warlock.js/ai"; // Re-run the turn with the decision pre-seeded: const outcome = await ai.human.resume( interruptId, { type: "edit", args: { confirm: true } }, { store, agent }, ); if (outcome.type === "applied" && outcome.result) { console.log(outcome.result.text); // the re-run completed with the ruling applied } ``` `ai.human.resume(interruptId, decision, options)` loads the `PendingInterrupt`, validates the decision shape, deletes the record, and — when an `agent` is supplied — re-executes the original prompt with the decision **pre-seeded** so the gated tool call resolves to the ruling instead of pausing again. The prompt comes from `request.context.originalInput`; pass `options.input` to override (e.g. to append the reviewer's note), and `options.executeOptions` to forward history / output schema / signal to the re-run. ### Two resume shapes | Shape | Pass | Behavior | |---|---|---| | **re-run** | `{ store, agent }` | Loads, deletes, re-executes the turn; `ResultResult.result` carries the `AgentResult`. | | **apply-only** | `{ store }` (no `agent`) | Loads, validates, deletes; returns `{ type: "applied", decision }` for a caller-owned re-drive (custom transport). No turn re-run. | ### Idempotent by construction ```ts type ResumeResult = | { type: "applied"; interruptId: string; decision: ApprovalDecision; result?: AgentResult } | { type: "already-resolved"; interruptId: string }; ``` A second resume of an already-resolved (deleted) or never-raised interrupt returns `{ type: "already-resolved" }` — it never double-applies the decision or re-runs the turn. The record is deleted **before** the re-run, so even a re-run that itself raises a fresh interrupt can't collide with the one being resolved. A malformed decision (`reject` with no `reason`, `edit` with no `args`, an unknown `type`) throws a `TypeError` loudly rather than silently mis-driving the re-run. ## The `InterruptStore` `ai.human.interrupt.{memory,pg,redis}()` build the store. The contract mirrors `@warlock.js/ai`'s `CheckpointStore` / `SnapshotStore` — `save` / `load` / `delete` / optional `list(prefix?)` / `schema()` — so a consumer already running an orchestrator can reuse the **same** pool for the interrupt table. | Factory | Backing | Deps | |---|---|---| | `ai.human.interrupt.memory()` | process-local `Map` | none — zero runtime deps | | `ai.human.interrupt.pg(options)` | one Postgres row per interrupt, keyed by `interrupt_id` | lazily imports the optional `pg` peer | | `ai.human.interrupt.redis(options)` | one namespaced JSON value + a self-maintained id index | lazily imports the optional `redis` peer | ```ts // Memory — dev / tests / single-process: const store = ai.human.interrupt.memory(); // Postgres — pass a live pool (core never imports pg in that case): import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const store = ai.human.interrupt.pg({ client: pool }); // Once, via your migration tool — the framework never auto-migrates: // await pool.query(store.schema()); // …or let the store build its own pool (lazily import("pg")): const store = ai.human.interrupt.pg({ connectionString: process.env.DATABASE_URL }); // Redis — pass a connected client, or a url: const store = ai.human.interrupt.redis({ url: process.env.REDIS_URL }); ``` ### Optional peers are lazy `pg` and `redis` are **optional** peer dependencies — neither is a hard dependency. The driver is imported only inside the store that needs it, and only when you pass a `connectionString` / `url` (passing a live `client` imports nothing). If the driver is absent, a **curated install string** surfaces on first use, never a raw module-resolution stack trace at import — so a memory-only consumer always loads cleanly. `PgClientLike` / `RedisClientLike` are structural interfaces, so any compatible pool/client satisfies them. `schema()` returns the reference DDL for the Postgres store (run it through your migration tool once) and an empty string for memory / redis, so callers treat `schema()` uniformly across drivers. ## See also - [`@warlock.js/ai/approve-tool-calls/SKILL.md`](@warlock.js/ai/approve-tool-calls/SKILL.md) — the gate itself: the interrupt policy, the approve / reject / edit decision union, and the interactive (in-process await) handler. - `@warlock.js/ai` — the `CheckpointStore` / `SnapshotStore` the `InterruptStore` mirrors, and the `ai.agent(...)` re-run target. ## embed-text `@warlock.js/ai/embed-text/SKILL.md` --- name: embed-text description: 'Text-to-vector via sdk.embedder({...}) — embed(string) for single, embedMany(string[]) for batch. Peer primitive on the SDK adapter, not wired into agents. Compose into RAG tools, workflow run steps, or ai.middleware.semanticCache. Triggers: `sdk.embedder`, `EmbedderContract`, `embedder.embed`, `embedder.embedMany`, `EmbeddingResult`, `EmbeddingBatchResult`, `dimensions`; ''embed text'', ''build RAG tool'', ''populate vector store'', ''embedding batch''; typical import `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: cache similarity — `@warlock.js/cache/use-cache-similarity/SKILL.md`; pgvector queries — `@warlock.js/cascade/search-by-vector/SKILL.md`; competing libs `langchain` embeddings, raw `openai.embeddings.create`.' --- # Embeddings — peer primitive on the SDK adapter `EmbedderContract` is a sibling of `ModelContract` on `SDKAdapterContract`, not part of the agent loop. Text-in / vector-out. No streaming, no tools, no relationship to chat completions. ## Contract ```ts interface EmbedderContract { readonly name: string; readonly provider: string; readonly dimensions: number; // 0 until first call when no override given embed(input: string): Promise; embedMany(inputs: string[]): Promise; } ``` Single and batch are deliberately split — different cost profiles, different per-request token caps, different failure modes. The `embedder()` method is **optional** on `SDKAdapterContract` — not every provider supports embeddings: ```ts if (typeof sdk.embedder === "function") { const embedder = sdk.embedder({ name: "text-embedding-3-small" }); } ``` ## OpenAI adapter — first implementation ```ts import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const embedder = openai.embedder({ name: "text-embedding-3-small" }); const one = await embedder.embed("Hello, world."); // { vector: number[], dimensions: number, usage: { promptTokens, totalTokens } } const many = await embedder.embedMany(["foo", "bar", "baz"]); // { vectors: number[][], dimensions: number, usage: { promptTokens, totalTokens } } ``` ## Not wired into the agent loop Embeddings are deliberately not automatic. Consumers obtain an embedder from the adapter and call it directly. Composes into: - **Retrieval tools** the agent can call (RAG pattern). - **`run` steps** in a workflow (vector ingest, catalog item embedding). - **Query vectors** for `ai.middleware.semanticCache` — see [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md). - **Cascade vector columns** for native pgvector search — see [`@warlock.js/cascade/search-by-vector/SKILL.md`](@warlock.js/cascade/search-by-vector/SKILL.md). - **Cache similarity retrieval** via `cache.set({ vector })` + `cache.similar(...)` — see [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md). ## Usage example — workflow `run` step ```ts ai.step({ name: "embed", run: async (ctx) => { const text = `${ctx.steps.extract.output.name} ${ctx.steps.extract.output.description}`; const { vector } = await embedder.embed(text); ctx.state.embedding = vector; }, output: { extract: (ctx) => ({ dims: (ctx.state.embedding as number[]).length }) }, }); ``` ## Pattern — RAG tool ```ts import { v } from "@warlock.js/seal"; const searchKb = ai.tool({ name: "searchKb", description: "Search the knowledge base for relevant passages.", input: v.object({ query: v.string(), k: v.number().optional() }), execute: async ({ query, k }) => { const { vector } = await embedder.embed(query); const hits = await vectorStore.query(vector, { topK: k ?? 5 }); return hits.map((h) => ({ text: h.text, score: h.score, source: h.source })); }, }); ai.agent({ model, tools: [searchKb] }); ``` ## Dimensions `embedder.dimensions` is `0` on a fresh embedder when no override is given — populated from the first embed call's response. Pre-seed via the adapter's `dimensions` config option when you need the value before the first call (e.g. to size a vector column in a migration schema). ## Retrieval is app-level No built-in vector store. Bring your own (pgvector / Qdrant / Pinecone / Chroma / cache's `similar()`) and wrap it in an `ai.tool({...})`. ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — composing embedders into tools - [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — embeddings inside `run` steps - [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — performance guidance on vector storage - [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — cache as a vector store - [`@warlock.js/cascade/search-by-vector/SKILL.md`](@warlock.js/cascade/search-by-vector/SKILL.md) — cascade `similarTo` query method ## escalate-block-to-human `@warlock.js/ai/escalate-block-to-human/SKILL.md` --- name: escalate-block-to-human description: 'Route a hard guardrail block to a human-review surface with @warlock.js/ai-guard — the `escalation.onBlock` seam and an `escalate: true` verdict. Triggers: `escalation`, `onBlock`, `GuardrailEscalation`, `GuardrailBlockEvent`, `escalate: true`, `{ type: "block", escalate: true }`, ''escalate a block to a human'', ''human review queue for guardrail'', ''page an operator on a guardrail block'', ''human-in-the-loop guardrail'', ''compose a block with a review surface'', ''custom detector that escalates''; typical import `import "@warlock.js/ai-guard"` then `ai.guardrail({ escalation: { onBlock } })`. Skip: composing the guard / phases / verdict model — `@warlock.js/ai-guard/guard-input-output/SKILL.md`; PII/moderation detectors — `@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md`; durable suspend/resume human-step machinery (deferred) — not in this package.' --- # Escalate a block to a human A `block` verdict can carry `escalate: true`. When it does, the guard `await`s your `escalation.onBlock(...)` handler **before** throwing the `GuardrailViolationError` — the seam to a human-review queue, an operator page, or any out-of-band approval surface. ```ts import { ai } from "@warlock.js/ai"; import "@warlock.js/ai-guard"; const policy = ai.guardrail({ output: [ai.guardrail.moderation({ blockOn: ["self-harm"] })], escalation: { async onBlock(event) { await reviewQueue.enqueue({ phase: event.phase, // "input" | "output" | "tool" reason: event.reason, // the detector's human-readable reason }); }, }, }); const agent = ai.agent({ model, middleware: [policy] }); ``` ## When `onBlock` fires `onBlock` fires **only** for a verdict of `{ type: "block", escalate: true }` — not for an ordinary `block`, and never for `allow` / `redact` / `flag`. It is **awaited before** the `GuardrailViolationError` is thrown, so your handler runs to completion (enqueue succeeds, the page is sent) before the error surfaces on `result.error`. The run still aborts: escalation is a *signal*, not a recovery — `execute()` returns with `result.error` populated as usual. ## The `GuardrailBlockEvent` payload `onBlock(event)` receives: | Field | Type | Meaning | |---|---|---| | `phase` | `"input" \| "output" \| "tool"` | where the block fired | | `reason` | `string` | the detector's human-readable reason | | `matches` | `readonly GuardrailMatch[] \| undefined` | what tripped the rule (rule id, span, label), when reported | | `ctx` | `MiddlewareTripContext` | the live trip context — `state`, `messages`, `agent`, `model`, `signal` | `ctx` lets the handler enrich the review item with run context (session id from `ctx.state`, the offending messages, etc.). ## Producing an escalating verdict The built-in detectors return ordinary `block` verdicts (no `escalate`). To escalate, author a tiny custom `GuardrailDetector` that sets `escalate: true` on its `block`: ```ts import type { GuardrailDetector } from "@warlock.js/ai-guard"; const wirePolicy: GuardrailDetector = { name: "wire-transfer", check(text) { if (/wire \$?\d{5,}/i.test(text)) { return { type: "block", reason: "large wire transfer requires human approval", escalate: true, // <- routes through escalation.onBlock matches: [{ rule: "wire-transfer.large", label: "wire" }], }; } return { type: "allow" }; }, }; const policy = ai.guardrail({ tool: [wirePolicy], toolNames: ["initiate_transfer"], escalation: { async onBlock(e) { await approvals.request(e); } }, }); ``` A `check()` may be sync or async (async = call an external service); the guard awaits either. ## A plain callback by design `escalation.onBlock` is a **plain callback** — `ai-guard` takes **no** dependency on the deferred durable human-step machinery (suspend/resume). The callback is the decoupling seam: inside it you wire your own review queue, and (where your stack supports it) a `workflow.resume(...)` loop. This package only emits the *signal*; it does not own durable suspension. When the typed human-step handoff ships, `onBlock` upgrades to it without a breaking change here. ## See also - [`@warlock.js/ai-guard/guard-input-output/SKILL.md`](@warlock.js/ai-guard/guard-input-output/SKILL.md) — composing the guard, the phases, the verdict model, and how a `block` surfaces on `result.error`. - [`@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md`](@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md) — the `pii` detector and the optional `moderation` peer that commonly drives an escalation. ## eval-datasets-and-ci `@warlock.js/ai/eval-datasets-and-ci/SKILL.md` --- name: eval-datasets-and-ci description: 'Datasets + regression-gated eval CI with ai.dataset({...}) feeding agent.eval({cases,baseline,tolerance}). Covers the immutable filterable/shardable dataset (cases / fromFile JSONL), DatasetEntry tags, EvalReport.regression (regressed/added/removed/passed) against a baseline, and the ai.eval reporters toJUnit / toJSON / fromJSON for CI artifacts + committed baselines. Triggers: `ai.dataset`, `DatasetContract`, `DatasetEntry`, `DatasetOptions`, `dataset.filter`, `dataset.shard`, `fromFile`, `agent.eval`, `EvalOptions`, `EvalReport`, `EvalCaseResult`, `EvalRegression`, `baseline`, `tolerance`, `ai.eval.toJUnit`, `ai.eval.toJSON`, `ai.eval.fromJSON`, `diff`, JSONL; ''eval dataset from a JSONL file'', ''shard an eval suite across CI jobs'', ''fail CI on an eval regression'', ''emit a JUnit report'', ''snapshot an eval baseline''; typical import `import { ai } from "@warlock.js/ai"`. Skip: the scorers + LLM-as-judge + Vitest matchers themselves — `@warlock.js/ai/ai-dx-helpers/SKILL.md` (registerAiMatchers / ai.eval.exact|contains|predicate|judge); record/replay of model calls for deterministic tests — `@warlock.js/ai/record-replay-llm/SKILL.md`; competing libs `promptfoo`, `braintrust`.' --- # `ai.dataset()` + `agent.eval()` regression CI Turn a corpus of cases into a regression-gated CI signal. `ai.dataset(...)` wraps cases into an immutable, filterable, shardable collection; `agent.eval({ cases, baseline, tolerance })` runs them, scores them, and diffs against a prior report; the `ai.eval.*` reporters serialize the result for CI ingestion and tomorrow's baseline. > This skill is the **dataset + CI** layer. The scorers, LLM-as-judge config, and Vitest matchers live in [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md); `agent.eval`'s core scoring loop is in [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md). ## `ai.dataset()` — immutable, filterable, shardable ```ts import { ai } from "@warlock.js/ai"; const ds = ai.dataset({ name: "support", cases: [{ name: "greeting", input: "hi", expected: "Hello" }], // inline entries fromFile: "./eval/support.jsonl", // JSONL read ONCE, synchronously, at construction }); ds.name; // "support" ds.cases; // DatasetEntry[] (inline first, then file entries appended) ``` - A `DatasetEntry` is an `EvalCase` plus optional `tags?: string[]` for filtering / sharding. - **`fromFile`** reads a JSONL file (one JSON object per line; blank lines skipped) synchronously at construction — mirroring `SystemPrompt.fromFile`. A malformed line throws an **`InvalidRequestError` naming the 1-based line number**; a missing/unreadable path throws too. `cases` and `fromFile` combine (file entries append after inline). ### `filter` / `shard` — derive new datasets ```ts const smoke = ds.filter((entry) => entry.tags?.includes("smoke")); const shard = ds.shard(0, 4); // first of four parallel CI shards ``` Both return a **fresh dataset sharing nothing mutable**. `shard(index, total)` is deterministic round-robin by position: every entry lands in exactly one shard, so the union of all `total` shards reproduces the full list with no gaps or overlaps. `shard` validates its args (positive integer `total`, `index` in `[0, total)`) and throws `InvalidRequestError` otherwise. ## `agent.eval({ cases })` — accepts a dataset directly ```ts const report = await myAgent.eval({ cases: ds, // a DatasetContract OR a raw EvalCase[] scorers: [ai.eval.contains()], }); expect(report.passed).toBe(true); // true only when EVERY case passed ``` The runner reads `.cases` off a dataset. Full `EvalReport`: `{ agentName, total, passedCount, failedCount, passRate, meanScore, passed, cases, duration, regression? }`. Each `EvalCaseResult` carries the case, the full `AgentResult`, every scorer's `scores`, the mean `score`, `passed`, and `duration`. ## Regression gating — `baseline` + `tolerance` ```ts import { readFile, writeFile } from "node:fs/promises"; const baseline = ai.eval.fromJSON(await readFile("./eval/baseline.json", "utf8")); const report = await myAgent.eval({ cases: ds, scorers: [ai.eval.exact()], baseline, // a prior EvalReport to diff against tolerance: 0.05, // max allowed per-case score DROP before it regresses. default 0 (any drop) }); if (report.regression && !report.regression.passed) { console.error("Regressed:", report.regression.regressed); // [{ name, before, after }] process.exit(1); } ``` When `baseline` is set the report carries a `regression` block (`EvalRegression`), joining cases by `name`: - **`regressed`** — `[{ name, before, after }]` for cases whose new score fell more than `tolerance` below baseline. - **`added`** / **`removed`** — case names present in only one report. Adding or dropping a case **never fails the gate by itself**. - **`passed`** — `true` when `regressed` is empty. The pure `diff(report, baseline, tolerance)` function (exported as `diff`) is the same logic, decoupled from the runner — depends only on the two reports and the tolerance, mutates neither. ## CI reporters — `ai.eval.toJUnit` / `toJSON` / `fromJSON` Pure functions over a finished `EvalReport`: ```ts // JUnit-XML artifact for CI ingestion — one (the agent), one per case, // a on each non-passing case (joined scorer reasons), times in SECONDS. await writeFile("./report.junit.xml", ai.eval.toJUnit(report)); // Round-trippable snapshot — today's report becomes tomorrow's baseline. await writeFile("./eval/baseline.json", ai.eval.toJSON(report)); const restored = ai.eval.fromJSON(await readFile("./eval/baseline.json", "utf8")); ``` `toJSON`/`fromJSON` preserve `result` payloads, per-case `scores`, timings, and any attached `regression` block, so a parsed report drives regression diffing exactly as the in-memory one. `toJUnit` hand-emits XML (no `xml` dependency) and entity-escapes every dynamic value. ## Typical CI shard job ```ts const shard = ai.dataset({ name: "support", fromFile: "./eval/support.jsonl" }) .shard(Number(process.env.SHARD_INDEX), Number(process.env.SHARD_TOTAL)); const report = await agent.eval({ cases: shard, scorers: [ai.eval.contains()], baseline: ai.eval.fromJSON(await readFile("./eval/baseline.json", "utf8")), tolerance: 0.05, }); await writeFile(`./out/report-${process.env.SHARD_INDEX}.junit.xml`, ai.eval.toJUnit(report)); if (report.regression && !report.regression.passed) process.exit(1); ``` ## See also - [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md) — `ai.eval.{exact,contains,predicate,judge}` scorers + Vitest matchers - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `agent.eval` scoring loop, `EvalCase` / `EvalJudge` - [`@warlock.js/ai/record-replay-llm/SKILL.md`](@warlock.js/ai/record-replay-llm/SKILL.md) — `ai.vcr` for deterministic, offline eval runs ## generate-images `@warlock.js/ai/generate-images/SKILL.md` --- name: generate-images description: 'Text-to-image via ai.image({ model: sdk.image({ name }), prompt }) — the image-OUTPUT verb (Theme I), returning the uniform never-throws { data, error, usage, report } envelope with cost-truth + panoptic observation. Models come from an adapter''s image() factory: OpenAI gpt-image-* (token-metered) / dall-e-* (per-image), Google gemini-* (generateContent + responseModalities IMAGE, usage passed through) / imagen-* and every other id (per-image, generateImages — deprecated by Google); the id picks the transport and is never validated locally. Result images are a discriminated GeneratedImage = { type: "base64" } | { type: "url" }. Triggers: `ai.image`, `sdk.image`, `openai.image`, `google.image`, `ImageModelContract`, `GeneratedImage`, `ImageModelPricing`; ''generate an image'', ''text to image'', ''gpt-image'', ''dall-e'', ''imagen'', ''product thumbnail'', ''image output''; typical import `import { ai } from "@warlock.js/ai"` + `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: image INPUT / vision attachments to a chat agent — `@warlock.js/ai/run-ai-agent/SKILL.md`; embeddings — `@warlock.js/ai/embed-text/SKILL.md`; competing libs raw `openai.images.generate`, `langchain` image tools.' --- # Generate images — the image-output verb (`ai.image`) `ai.image()` is the output counterpart to `ai.agent` for the image modality (the first verb of the output-modality track, Theme I). Prompt-in / images-out, wrapped in the same uniform result contract every executable returns — so it slots into cost dashboards and panoptic traces exactly like an agent run. This is image **output** (generation). For image/PDF/audio **input** to a chat agent (vision), see [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md). ## Shape ```ts // 1. Build an image model from an adapter's image() factory. const model = openai.image({ name: "gpt-image-1" }); // ImageModelContract // 2. Run the verb — never throws; failures land on result.error. const { data, error, usage, report } = await ai.image({ model, prompt: "a red bicycle" }); if (error) { console.warn(error.code); // typed AIError (auth / rate-limit / content-filter / …) } else { for (const img of data.images) { // GeneratedImage[] if (img.type === "base64") save(Buffer.from(img.base64, "base64"), img.mediaType); else download(img.url); } } ``` `ImageModelContract` mirrors `EmbedderContract` — a peer primitive on the SDK adapter, produced by the optional `image?()` factory. An adapter without an image API simply doesn't define `image()`, so `ai.anthropic.image(...)` is a **compile-time** error, not a silent runtime failure. ## The result envelope ```ts type ImageResult = { type: "image"; data?: { images: GeneratedImage[] }; // undefined on failure error?: AIError; // undefined on success — NEVER thrown usage: Usage; // tokens (gpt-image) + cost when priced report: ImageReport; // type:"image", model, imageCount, lineage }; type GeneratedImage = | { type: "base64"; base64: string; mediaType: string; revisedPrompt?: string } | { type: "url"; url: string; mediaType?: string; revisedPrompt?: string }; ``` ## Generation options (provider-neutral) ```ts await ai.image({ model, prompt: "an isometric office desk, soft studio lighting", count: 2, // n images size: "1024x1024", // OpenAI WxH (also resolves perImageBySize pricing) quality: "high", // OpenAI quality tier aspectRatio: "16:9", // Imagen ratio negativePrompt: "blurry, watermark", // Imagen format: "png", // output container hint signal, // AbortSignal observe: collector, // route the report to an Observer (panoptic), like agents sessionId: "checkout-123", options: { background: "transparent" }, // provider-specific passthrough }); ``` Each adapter maps the options its API supports and ignores the rest. `options` is the escape hatch for provider-specific knobs (OpenAI `background`, DALL·E `responseFormat: "url"`, Imagen `imageSize` / `personGeneration`). ## OpenAI — gpt-image (token-metered) + DALL·E (per-image) ```ts import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); // gpt-image-1 always returns base64 bytes; priced per TOKEN. const gpt = openai.image({ name: "gpt-image-1", pricing: { input: 5, output: 40 } }); // DALL·E 3 — per-image pricing; defaults to base64 (opt into url with options). const dalle = openai.image({ name: "dall-e-3", pricing: { perImage: 0.04 } }); ``` The model id is **not validated locally**. `openai.image({ name })` forwards the id to `client.images.generate` exactly as given, so a non-image id (`openai.image({ name: "gpt-4o" })`) constructs fine and fails at OpenAI — as a typed provider error on `result.error`, never as a local throw at construction. ## Google — Imagen (per-image) and Gemini (per-token) ```ts import { GoogleSDK } from "@warlock.js/ai-google"; const google = new GoogleSDK({ apiKey: process.env.GEMINI_API_KEY! }); // Imagen — per-image-metered, via ai.models.generateImages: const imagen = google.image({ name: "imagen-4.0-generate-001", pricing: { perImage: 0.04 } }); // Gemini image model — token-metered, via ai.models.generateContent: const gemini = google.image({ name: "gemini-3.1-flash-lite-image", pricing: { input: 0.3, output: 30 } }); const { data } = await ai.image({ model: imagen, prompt: "a watercolor lighthouse at dawn", aspectRatio: "3:4" }); ``` The **id picks the transport**: a `gemini-` id goes to `generateContent` (token `usage` is passed through as Google reports it — price with `{ input, output }`), anything else to `generateImages` (Imagen — always zero usage, price with `{ perImage }`). Both surface images in the same `GeneratedImage` shape (base64 bytes, no hosted URL). When Google filters the request, `ai.image` surfaces a typed `ContentFilterError` on `result.error`; a Gemini response that answered with text instead of an image surfaces a `ProviderError` quoting that text. ⚠ **No test calls the live API**, so the Gemini path rests on two tiers of evidence. Measured here: a `gemini-*` id reached `generateContent` and returned a quota error (429) where `generateImages` returned 404 — the endpoint accepts the id. Reported by the maintainer: with billing enabled, an image comes back end-to-end. Still unknown is whether these models report token usage — no `usageMetadata` from a successful image call has been seen. Google has also **deprecated `generateImages`** ("will be removed in the next major release (not before Jan. 1 2027)"), so the Imagen path is on a clock. Like every adapter, Google does **not** guard the model id — the id selects a route, it is never refused locally, so an id Google does not serve fails as a typed provider error on `result.error`, not with a local throw at construction. ## Cost-truth — one rollup, two metering models `ai.image` fills `usage.cost` (a `ModelPricing`-shaped USD breakdown) so image spend folds into the **same** `Usage.cost` rollup as text — no second accounting path: - **Token-metered** (gpt-image-1): `{ input, output }` USD-per-1M-tokens → standard `computeCost` against the returned token usage. - **Per-image** (DALL·E, Imagen): `{ perImage }` (or `perImageBySize["1792x1024"]`) × image count → `cost.output`. Unpriced model → `usage.cost` stays `undefined` (honest "cost unknown", never a false zero). A pre-priced adapter response is honored, not overwritten. ## Pattern — catalog thumbnail in a workflow `run` step ```ts ai.step({ name: "thumbnail", run: async (ctx) => { const { data, error } = await ai.image({ model: openai.image({ name: "gpt-image-1" }), prompt: `product photo, white background: ${ctx.steps.extract.output.title}`, size: "1024x1024", }); if (error) throw error; // step retry/backoff handles transient provider faults ctx.state.thumb = data.images[0]; }, }); ``` ## Observability The completed `ImageReport` routes to any registered `Observer` (panoptic, OTel, …) through the shared `observe` seam — pass `observe: true` (global), an `Observer` object (flow-local), or rely on observe-all. Cost + latency attribute to `report.model` for free. See [`@warlock.js/ai/observe-ai-flows/SKILL.md`](@warlock.js/ai/observe-ai-flows/SKILL.md). ## Testing `MockSDK({ imageResponses, imagePricing }).image({ name })` returns a deterministic `MockImageModel` — no HTTP. Script images/usage/errors and inspect `model.calls`. ```ts import { MockSDK } from "@warlock.js/ai"; const mock = MockSDK({ imageResponses: [{}], imagePricing: { perImage: 0.04 } }); const { data, usage } = await ai.image({ model: mock.image({ name: "mock-image" }), prompt: "x" }); ``` ## generate-speech `@warlock.js/ai/generate-speech/SKILL.md` --- name: generate-speech description: 'Text-to-speech via ai.speech({ model: sdk.speech({ name }), text }) — the audio-OUTPUT verb (Theme I), returning the uniform never-throws { data, error, usage, report } envelope with cost-truth + panoptic observation. Models come from an adapter''s speech() factory: OpenAI tts-1 / tts-1-hd (per-character) or gpt-4o-mini-tts (per-token). Synthesized audio is a discriminated GeneratedAudio = { type: "base64"; base64; mediaType }. Options: voice / format / speed / instructions / signal. Triggers: `ai.speech`, `sdk.speech`, `openai.speech`, `SpeechModelContract`, `GeneratedAudio`, `SpeechModelPricing`, `SpeechOptions`, `MockSpeechModel`; ''text to speech'', ''TTS'', ''synthesize voice'', ''read this aloud'', ''tts-1'', ''gpt-4o-mini-tts'', ''voice narration'', ''audio output'', ''speak this text''; typical import `import { ai } from "@warlock.js/ai"` + `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: speech-to-text / transcribing a voice note — [[transcribe-audio]]; image OUTPUT — [[generate-images]]; competing libs raw `openai.audio.speech.create`, `elevenlabs` SDK.' --- # Generate speech — the text-to-speech verb (`ai.speech`) `ai.speech()` is the audio-output counterpart to `ai.image()` on the output-modality track (Theme I). Text-in / audio-out, wrapped in the same uniform result contract every executable returns — so a synthesized voicemail slots into cost dashboards and panoptic traces exactly like an agent run. This is audio **output** (TTS). For audio **input** (speech-to-text on a WhatsApp voice note or a meeting recording), see [[transcribe-audio]]. ## Shape ```ts // 1. Build a speech model from an adapter's speech() factory. const model = openai.speech({ name: "tts-1", voice: "alloy" }); // SpeechModelContract // 2. Run the verb — never throws; failures land on result.error. const { data, error, usage, report } = await ai.speech({ model, text: "Your order has shipped." }); if (error) { console.warn(error.code); // typed AIError (auth / rate-limit / content-filter / …) } else { const { base64, mediaType } = data.audio; // GeneratedAudio (always base64 today) await fs.writeFile("ship.mp3", Buffer.from(base64, "base64")); } ``` `SpeechModelContract` mirrors `EmbedderContract` / `ImageModelContract` — a peer primitive produced by the adapter's optional `speech?()` factory. An adapter without a TTS API simply doesn't define `speech()`, so calling it is a **compile-time** error, not a silent runtime failure. The model id itself is **not validated locally** — a non-TTS id (`openai.speech({ name: "gpt-4o" })`) constructs fine and is forwarded to the provider as given, so it fails as a typed provider error on `result.error`, never as a local throw at construction. ## The result envelope ```ts type SpeechResult = { type: "speech"; data?: { audio: GeneratedAudio }; // undefined on failure error?: AIError; // undefined on success — NEVER thrown usage: Usage; // tokens (gpt-4o-mini-tts) + cost when priced report: SpeechReport; // type:"speech", model, characters, lineage }; type GeneratedAudio = { type: "base64"; base64: string; // base64-encoded audio bytes mediaType: string; // IANA type, e.g. "audio/mpeg", "audio/wav" }; ``` `GeneratedAudio` is a discriminated union with a single `base64` variant today — the union leaves room for a future hosted-`url` variant without a breaking change, so always branch on `audio.type` rather than assuming `base64`. ## Generation options (provider-neutral) ```ts await ai.speech({ model, text: "Welcome aboard. Let's get you set up.", voice: "verse", // voice id/name; overrides the model's default format: "wav", // "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm" speed: 1.25, // playback multiplier (OpenAI 0.25–4.0) instructions: "calm, warm", // tone/delivery steering (gpt-4o-mini-tts only) signal, // AbortSignal observe: collector, // route the report to an Observer (panoptic), like agents sessionId: "onboarding-42", // group into a session for flat cost/trace queries options: { /* provider passthrough */ }, }); ``` Each adapter maps the options its API supports and forwards `options` verbatim. On OpenAI the container defaults to `mp3` (→ `audio/mpeg`); `speed` and `instructions` are only sent when set, and the default voice is `alloy` when neither the call nor the model config supplies one. ## OpenAI — tts-1 (per-character) + gpt-4o-mini-tts (per-token) ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); // tts-1 / tts-1-hd — billed per INPUT CHARACTER. const classic = openai.speech({ name: "tts-1", voice: "alloy", pricing: { perMillionCharacters: 15 } }); // gpt-4o-mini-tts — billed per TOKEN like a chat model; supports `instructions`. const steered = openai.speech({ name: "gpt-4o-mini-tts", pricing: { input: 0.6, output: 12 } }); const { data } = await ai.speech({ model: steered, text: "Read this warmly.", instructions: "gentle" }); ``` ## Cost-truth — one rollup, two metering models `ai.speech` fills `usage.cost` (a USD breakdown) so TTS spend folds into the **same** `Usage.cost` rollup as text — no second accounting path: - **Per-character** (`tts-1` / `tts-1-hd`): `{ perMillionCharacters }` × `report.characters` → `cost.input`. The Speech API reports no token usage, so `usage` tokens stay `{ 0, 0, 0 }` and spend is priced entirely from the input character count. - **Token-metered** (`gpt-4o-mini-tts`): `{ input, output }` USD-per-1M-tokens → standard `computeCost` against the returned token usage. Per-character wins when both are set. An unpriced model leaves `usage.cost` **`undefined`** (honest "cost unknown", never a false zero); a pre-priced adapter response is honored, not overwritten. ## Pattern — order-confirmation voice line in a workflow `run` step ```ts ai.step({ name: "voiceLine", run: async (ctx) => { const { data, error } = await ai.speech({ model: openai.speech({ name: "tts-1", voice: "alloy" }), text: `Order ${ctx.steps.order.output.id} confirmed. Thank you!`, format: "mp3", }); if (error) throw error; // step retry/backoff handles transient provider faults ctx.state.audio = data.audio; // { type:"base64", base64, mediaType:"audio/mpeg" } }, }); ``` ## Observability The completed `SpeechReport` (with `report.characters` and cost/latency attributed to `report.model`) routes to any registered `Observer` (panoptic, OTel, …) through the shared `observe` seam — pass `observe: true` (global), an `Observer` object (flow-local), or rely on observe-all. See [[observe-ai-flows]]. Provider faults surface as typed `AIError`s on `result.error`; see [[handle-ai-errors]]. ## Testing `MockSpeechModel(name, responses, pricing?)` is a deterministic `SpeechModelContract` double — no HTTP. Script audio/usage/errors and inspect `model.calls`. `MockSDK({ speechResponses, speechPricing }).speech({ name })` wires the same double behind a full adapter. ```ts import { MockSpeechModel } from "@warlock.js/ai"; import { speech } from "@warlock.js/ai"; const model = new MockSpeechModel("tts-1", [{}], { perMillionCharacters: 15 }); const { data, usage } = await speech({ model, text: "abcdefghij" }); // 10 chars // data.audio → { type:"base64", base64:"AAAA", mediaType:"audio/mpeg" } // usage.cost.input → (10 * 15) / 1_000_000 // model.calls[0] records { text, options } for assertions ``` Scripting `[{ error: new ProviderRateLimitError("slow down") }]` drives the never-throws path — `result.error` is the typed error and `result.data` is `undefined`. ## See also - [[transcribe-audio]] — the inverse verb (`ai.transcribe`), audio → text - [[generate-images]] — the sibling image-output verb (`ai.image`) - [[observe-ai-flows]] — routing the `SpeechReport` to panoptic / OTel - [[handle-ai-errors]] — the typed `AIError` taxonomy on `result.error` ## guard-input-output `@warlock.js/ai/guard-input-output/SKILL.md` --- name: guard-input-output description: 'Build the composed guardrail middleware with @warlock.js/ai-guard and wire it into an agent — `ai.guardrail({ input, output, tool, toolNames, escalation })`. Triggers: `ai.guardrail`, `guard`, `GuardOptions`, `GuardrailVerdict`, `GuardrailDetector`, `GuardrailPhase`, `GuardrailMatch`, `GuardrailViolationError`, `ai.guardrail.topic`, `ai.guardrail.injection`, `topicFilter`, `injectionDetector`, `toolNames`, `forTool`; ''add a guardrail to my agent'', ''block prompt injection'', ''filter banned topics'', ''guard agent input and output'', ''stop the model leaking data into a tool call'', ''scope a detector to one tool''; typical import `import "@warlock.js/ai-guard"` (registers `ai.guardrail`) or `import { guard } from "@warlock.js/ai-guard"`. Skip: PII detection/redaction specifically — `@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md`; routing a block to a human — `@warlock.js/ai-guard/escalate-block-to-human/SKILL.md`; the core middleware pipeline / hook contract — `@warlock.js/ai/run-ai-agent/SKILL.md`.' --- # Guard agent input, output, and tool args `ai.guardrail(...)` is a **middleware factory**. It produces one `AgentMiddleware` that runs your detectors at three hook points and maps each verdict onto the agent pipeline's existing throw / return / record mechanics. Importing the package registers the verb (and its attached detector factories) on the shared `ai` namespace: ```ts import { ai } from "@warlock.js/ai"; import "@warlock.js/ai-guard"; // registers ai.guardrail + ai.guardrail.pii/.topic/.injection/.moderation const policy = ai.guardrail({ name: "compliance", input: [ai.guardrail.injection({ onMatch: "block" })], output: [ai.guardrail.topic({ deny: [/medical advice/i, "diagnosis"], onMatch: "block" })], }); const agent = ai.agent({ model, middleware: [policy] }); ``` A named-export form is available for callers who prefer not to rely on the augmented namespace: ```ts import { guard, topic, injection } from "@warlock.js/ai-guard"; const policy = guard({ input: [injection({ onMatch: "block" })] }); ``` ## The three phases | Phase | Hook | Inspected text | Set with | |---|---|---|---| | **input** | `trip.before` | the outbound prompt (`extractUserText(ctx.messages)`) | `input: [...]` | | **output** | `trip.after` | `response.content` | `output: [...]` | | **tool** | `tool.before` | `JSON.stringify(toolArgs)` | `tool: [...]` | Each phase array runs its detectors in **registration order**; the first non-`allow` verdict decides the action for that phase (short-circuit). A phase you don't configure is inert — a guard with no detectors is a no-op middleware. ## The verdict model A detector inspects text and returns a `GuardrailVerdict`, discriminated by `type` (never `kind`): | `type` | Effect | |---|---| | `allow` | Pass to the next detector. | | `redact` | Rewrite the inspected text and continue — **output phase only** (see limitation below). | | `block` | Short-circuit with the existing `GuardrailViolationError`. | | `flag` | Pass, but append a `FlagRecord` into `ctx.state` under `.flags` for a downstream observer (panoptic, the caller). | `agent.execute()` **never throws** — a `block` surfaces on `result.error` as a `GuardrailViolationError`, exactly like every other `AIError`. Branch on it after the run: ```ts const result = await agent.execute(userInput); if (result.error instanceof ai.errors.GuardrailViolationError) { // result.error.phase is "input" | "output" | "tool" // result.error.reason / result.error.guardrail carry the detail } ``` ## Built-in detectors Three zero-dependency detectors ship (a fourth, `moderation`, is an optional `openai` peer — see [`detect-and-redact-pii/SKILL.md`](@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md)): - **`ai.guardrail.injection(options?)`** — jailbreak / prompt-injection marker phrases. Extra `markers` (string | RegExp); `onMatch` defaults to `"flag"`, callers commonly use `"block"` on input. - **`ai.guardrail.topic(options)`** — `deny` (string substring | RegExp) and/or `allow` (allow-list miss triggers `onMatch`). `onMatch` is `"block" | "flag"`, default `"block"`. - **`ai.guardrail.pii(options?)`** — PII regex + dictionary (its own skill). ```ts const policy = ai.guardrail({ input: [ ai.guardrail.injection({ onMatch: "block", markers: ["ignore previous instructions"] }), ai.guardrail.topic({ deny: ["competitor-name"], onMatch: "block" }), ], }); ``` ## Scope tool detectors to specific tools `tool` detectors fire on **every** tool call by default. Set `toolNames` to scope them — the whole middleware is wrapped with the core `forTool(toolNames, mw)` helper so the `tool` hooks fire only for those names; `input` / `output` (`trip`) hooks are unaffected: ```ts const policy = ai.guardrail({ tool: [ai.guardrail.pii({ onMatch: "block" })], // stop PII reaching the tool toolNames: ["send_email", "post_webhook"], // ...only for these tools }); const agent = ai.agent({ model, tools: [sendEmail, postWebhook, lookup], middleware: [policy] }); // `lookup` runs unguarded; `send_email` / `post_webhook` block on PII in their args. ``` A `block` from `tool.before` aborts that tool dispatch and surfaces on `result.error` with `phase: "tool"` — the agent run itself still never crashes. ## Install order A guard is a normal `AgentMiddleware`; registration order is execution order (`before` top-down, `after` bottom-up). The canonical order is `[cache, budget, guardrail, observability]`. A `semanticCache` that short-circuits `trip.before` runs *before* the guard — a cached response then skips the **output** detectors, so place the guard before the cache if you don't trust cached contents. ## Input-redaction limitation (v1) The core `trip.before` hook can only **short-circuit** (return a `ModelResponse`); it cannot rewrite the outbound prompt and continue. So: - **Input detectors are `block` / `flag` only.** A `redact` verdict on an input detector is treated as a `block` rather than silently passing an un-redacted prompt. - **Output redaction works** — `trip.after` returns a replacement `ModelResponse` with the rewritten `content`. - **Tool-arg `redact` is also withheld** — it downgrades to a `block` (`reason: "tool-arg-redaction-unsupported"`), because silently rewriting tool arguments changes the call's side-effects unpredictably. Lifting the input limitation needs a small, non-breaking core affordance and is deferred. ## Failure isolation A detector's `check()` **rejecting** is an infrastructure fault, not a content violation — it is recorded as a `flag` (`.error`) into `ctx.state` and the fold **continues** (fail-open). A moderation-API outage degrades to missing annotation, never a failed agent run. ## See also - [`@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md`](@warlock.js/ai-guard/detect-and-redact-pii/SKILL.md) — the `pii` detector (detect/redact/block), the `mask` template, and the optional `moderation` peer. - [`@warlock.js/ai-guard/escalate-block-to-human/SKILL.md`](@warlock.js/ai-guard/escalate-block-to-human/SKILL.md) — routing a `block` to a human-review surface via `escalation.onBlock`. - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent, the middleware pipeline, and the `GuardrailViolationError` on `result.error`. ## handle-ai-errors `@warlock.js/ai/handle-ai-errors/SKILL.md` --- name: handle-ai-errors description: 'Typed AIError hierarchy with stable code strings + coarse category for retry-policy dispatch. execute() never throws — errors surface via result.error (the sole exception: OrchestratorConfigError throws at construction). Triggers: `AIError`, `ProviderRateLimitError`, `ProviderAuthError`, `ContextLengthExceededError`, `ContentFilterError`, `SchemaValidationError`, `ToolExecutionError`, `WorkflowDriftError`, `SupervisorDriftError`, `SupervisorFailedError`, `SupervisorRoutingError`, `OrchestratorFailedError`, `OrchestratorDriftError`, `OrchestratorConfigError`, `OrchestratorCancelledError`, `PlannerFailedError`, `PlannerPlanInvalidError`, `PlannerCancelledError`, `BudgetExceededError`, `GuardrailViolationError`, `error.code`, `error.category`; ''handle ai error'', ''retry on rate limit'', ''branch on error code'', ''ORCHESTRATOR_DRIFT'', ''PLANNER_PLAN_INVALID'', ''build fallback ladder''; typical import `import { AIError } from "@warlock.js/ai"`. Skip: log surfacing — `@warlock.js/ai/log-ai-calls/SKILL.md`; native `try / catch` on raw `openai`.' --- # Typed errors — `AIError` hierarchy Every error surfaced by `@warlock.js/ai` and every adapter package is an `AIError` subclass with a stable `code`. The base extends platform `Error`; it does NOT extend `HttpError`. Plain `Error` never leaks. ## Two invariants 1. **`execute()` never throws.** Every `agent.execute()` / `workflow.execute()` resolves with a well-formed result. Failures funnel into `result.error`. Same for `stream.result`. 2. **Every error is an `AIError`.** Both core and adapter packages funnel everything through `AIError` subclasses. Branch on `error.code` (stable string) or `instanceof`. ## Dispatch pattern ```ts import { AIError, ProviderRateLimitError, ProviderAuthError, ContextLengthExceededError, ContentFilterError, SchemaValidationError, ToolExecutionError, WorkflowDriftError, // ... } from "@warlock.js/ai"; const result = await agent.execute(input); if (!result.error) return result.data; if (result.error instanceof ProviderRateLimitError) { await sleep(result.error.retryAfter ?? 1000); return retry(); } if (result.error instanceof ContextLengthExceededError) { return truncateAndRetry(result.error); } // Or branch on stable code string (good for persisted logs / metrics) switch (result.error.code) { case "PROVIDER_RATE_LIMIT": /* ... */ break; case "CONTENT_FILTER": /* ... */ break; case "WORKFLOW_DRIFT": /* ... */ break; } ``` Codes are the public contract — class names may evolve; codes stay. ## Coarse dispatch via `error.category` Too granular to dashboard on `code` — every `AIError` carries a coarser `category`: ```ts type ErrorCategory = | "auth" | "rate-limit" | "timeout" | "validation" | "content-filter" | "provider" | "tool" | "cancelled" | "max-trips" | "max-iterations" | "max-steps" | "schema" | "drift" | "routing" | "guardrail" | "budget" | "quota" | "context-length" | "unknown"; switch (result.error.category) { case "rate-limit": return retryWithBackoff(); case "timeout": return retryOnce(); case "auth": return escalate(); // not retryable case "content-filter": return policyMessage(); // not retryable case "schema": return repair(); // use agent `repair` } metrics.increment("ai.error", { category: result.error.category }); ``` Each typed subclass declares its `static defaultCategory`. The 4th-arg category override exists only on the base `AIError` for direct `new AIError(...)` usage. ## Hierarchy ``` AIError (base — code, category, message, cause?, context?) ├── AgentExecutionError AGENT_EXEC_FAILED │ ├── AgentCancelledError AGENT_CANCELLED { cancelledAt?, reason? } — caller pulled the plug │ └── AgentMaxTripsError AGENT_MAX_TRIPS { maxTrips } — runaway tool loop hit the cap ├── SchemaValidationError SCHEMA_VALIDATION_FAILED { issues? } ├── ToolExecutionError TOOL_EXEC_FAILED { toolName, tripIndex? } ├── WorkflowError WORKFLOW_ERROR (base) │ ├── StepFailedError STEP_FAILED { stepName, attempts } │ ├── WorkflowDriftError WORKFLOW_DRIFT { savedSignature, currentSignature, runId } │ ├── WorkflowCancelledError WORKFLOW_CANCELLED { cancelledAt, reason } │ ├── MaxStepsExceededError WORKFLOW_MAX_STEPS { maxSteps } │ └── RoutingError WORKFLOW_INVALID_GOTO { stepName, targetName } ├── SupervisorFailedError SUPERVISOR_FAILED (base + authoring/runtime) │ ├── MaxIterationsError SUPERVISOR_MAX_ITERATIONS { maxIterations } │ ├── SupervisorRoutingError SUPERVISOR_INVALID_ROUTE { returned, availableKeys } │ ├── SupervisorCancelledError SUPERVISOR_CANCELLED { cancelledAt, reason } │ └── SupervisorDriftError SUPERVISOR_DRIFT { savedSignature, currentSignature, runId } ├── OrchestratorFailedError ORCHESTRATOR_FAILED (base — durable-session turn) │ ├── OrchestratorConfigError ORCHESTRATOR_CONFIG authoring-time, THROWS (validation) — bad ai.orchestrator(config) │ ├── OrchestratorDriftError ORCHESTRATOR_DRIFT { savedSignature, currentSignature, sessionId } (drift) — checkpoint ≠ definition │ └── OrchestratorCancelledError ORCHESTRATOR_CANCELLED { cancelledAt, sessionId, reason } (cancelled) — mid-turn abort ├── PlannerFailedError PLANNER_FAILED (base — plan generation/execution) │ ├── PlannerPlanInvalidError PLANNER_PLAN_INVALID (schema) — LLM plan unparseable or names an unregistered capability │ └── PlannerCancelledError PLANNER_CANCELLED { cancelledAt, reason } (cancelled) — mid-plan abort ├── ProviderError PROVIDER_ERROR (base + catch-all) │ ├── ProviderRateLimitError PROVIDER_RATE_LIMIT { retryAfter? } — transient │ ├── QuotaExceededError PROVIDER_QUOTA_EXCEEDED — NOT retryable (billing cap) │ ├── ProviderTimeoutError PROVIDER_TIMEOUT │ ├── ContextLengthExceededError CONTEXT_LENGTH_EXCEEDED { limit?, actual?, modelName? } │ ├── ContentFilterError CONTENT_FILTER { reason?, categories? } │ ├── InvalidRequestError PROVIDER_INVALID_REQUEST │ └── ProviderAuthError PROVIDER_AUTH ├── BudgetExceededError BUDGET_EXCEEDED { limit, actual, unit } — from ai.middleware.budget └── GuardrailViolationError GUARDRAIL_VIOLATION { phase, reason } — from ai.middleware.guardrail ``` > `SupervisorFailedError` doubles as the base for the supervisor family **and** the authoring-time error for bad config (e.g. `route` + `router` both set). It carries extra `SUPERVISOR_INTENT_*` / `SUPERVISOR_DISPATCH_CYCLE` codes for specific intent-validation failures (`SUPERVISOR_INTENT_DESCRIPTION_REQUIRED`, `SUPERVISOR_INTENT_MIXED_DISPATCH`, `SUPERVISOR_INTENT_STREAM_AND_OUTPUT`, `SUPERVISOR_INTENT_STREAM_TO_REQUIRED`, `SUPERVISOR_INTENT_STREAM_ON_WORKFLOW`, `SUPERVISOR_DISPATCH_CYCLE`). > **Orchestrator + planner families** anchor on `OrchestratorFailedError` / `PlannerFailedError` (the `ORCHESTRATOR_*` / `PLANNER_*` code families). Both follow the never-throw rule: `orchestrator.execute()` / `resume()` / `command()` and `planner.execute()` surface failures on `result.error` with `report.status` `"failed"` / `"cancelled"`. The **one exception** is `OrchestratorConfigError` (`ORCHESTRATOR_CONFIG`) — an authoring-time misconfiguration (`iterate: true` with no resolvable `snapshotStore`, no `checkpointStore`, both `route` and `router` set, `initialAgent` absent from `intents`) that **throws synchronously at construction** so a bad definition fails fast at boot. Child-execution errors (agent / tool / provider / supervisor / workflow) flow through both primitives **unchanged** — captured on the step / turn report and surfaced on `result.error` directly, never re-wrapped into a `PLANNER_*` / `ORCHESTRATOR_*` code. On an `iterate: true` mid-turn cancel the underlying `SupervisorCancelledError` rides on `OrchestratorCancelledError.cause`. ## Error fields - `code` — stable `AIErrorCode` string. - `category` — coarse `ErrorCategory`. - `message` — human-readable. - `cause?` — root error (often a provider SDK error). - `context?` — `Record` for provider-raw diagnostics (`status`, `requestId`, `headers`). Typed fields (`retryAfter`, `toolName`, `issues`, `stepName`, …) are first-class consumer surface. ## Retry strategy | Error family | Retryable? | | --- | --- | | `ProviderRateLimitError` | Yes — back off by `retryAfter` ms | | `ProviderTimeoutError` | Yes — short delay | | `ProviderError` (generic) | Maybe — depends on cause | | `QuotaExceededError` | **No** — needs human intervention | | `ProviderAuthError` | **No** — fix config / rotate key | | `ContextLengthExceededError` | Only after truncating input | | `ContentFilterError` | Usually **no** — the prompt itself is the issue | | `SchemaValidationError` | Use agent `repair: { maxAttempts }` instead | | `ToolExecutionError` | Depends on `cause` | | `WorkflowDriftError` / `SupervisorDriftError` / `OrchestratorDriftError` | **No** — manual migration or `force: true` | | `WorkflowCancelledError` / `SupervisorCancelledError` / `OrchestratorCancelledError` / `PlannerCancelledError` | **No** — caller-driven cancel | | `MaxStepsExceededError` / `RoutingError` / `SupervisorRoutingError` | **No** — programmer error | | `OrchestratorConfigError` | **No** — authoring-time config bug; thrown at construction | | `PlannerPlanInvalidError` | **No** — bad LLM plan / unregistered capability; re-prompt or fix the capability roster | | `BudgetExceededError` | **No** — raise the cap, split the workload | | `GuardrailViolationError` (`phase: "input"`) | **No** — block / sanitize at product layer | | `GuardrailViolationError` (`phase: "output"`) | Sometimes — re-prompt with adjusted system message | ## Why extend `Error`, not `HttpError` - `@warlock.js/ai` is a standalone product — used from CLIs / workers / scripts as often as HTTP handlers. - Coupling to a web framework pulls HTTP into every consumer. - AI errors aren't HTTP errors anyway — "rate limit" is a 429 the *upstream provider* returned, not one the server returns. The **consumer app** layer (`src/app/ai/`) wraps framework errors with its own `AIError` subclass that extends `HttpError`. See `domains/ai/conventions/errors.md`. ## OpenAI adapter — status + code dispatch The OpenAI wrapper categorizes via `APIError.status + code` combined: - `APIError.code` is semantically stable (`context_length_exceeded`, `content_filter`, `invalid_api_key`, etc.) across SDK versions; message strings are not. - Status alone collapses three distinct failure modes into one bucket (`400` = context-length OR content-filter OR bad-model-name). - When `code` is missing (proxied deployments), fall back to `status`. - Name-based detection catches `APIConnectionTimeoutError` and Node-level `ETIMEDOUT` / `ECONNABORTED`. ## Pattern — full fallback ladder ```ts async function runWithFallbacks(input: string) { for (let attempt = 0; attempt < 3; attempt++) { const { data, error } = await myAgent.execute(input); if (!error) return data; if (error instanceof ProviderRateLimitError) { await sleep(error.retryAfter ?? 2000); continue; } if (error instanceof ContextLengthExceededError) { input = truncate(input, error.limit ?? 4000); continue; } if (error instanceof QuotaExceededError || error instanceof ProviderAuthError) { throw error; // not retryable } throw error; // unknown — give up } throw new Error("exhausted retries"); } ``` ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `AgentResult.error` - [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — `WorkflowError` subclasses - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — `SupervisorFailedError` family + intent-validation codes - [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md) — `OrchestratorFailedError` family + `ORCHESTRATOR_CONFIG` boot-time throw - [`@warlock.js/ai/run-planner/SKILL.md`](@warlock.js/ai/run-planner/SKILL.md) — `PlannerFailedError` family + `PLANNER_PLAN_INVALID` - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — `ToolExecutionError` wrapping - [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md) — error logging - `domains/ai/conventions/errors.md` — framework vs app error convention ## log-ai-calls `@warlock.js/ai/log-ai-calls/SKILL.md` --- name: log-ai-calls description: 'Framework logging delegated to @warlock.js/logger — every primitive emits via the log singleton, configure channels / levels / redaction once at boot. Four-arg call convention (module, action, message, context). Triggers: `log.configure`, `log.setMinLevel`, `log.setChannels`, `ConsoleLog`, `FileLog`, `LogChannel`, `redact.paths`, `ai.agent.` / `ai.workflow.` / `ai.supervisor.` modules; ''configure ai logging'', ''mask prompts in logs'', ''silence logs in tests'', ''capture log entries''; typical import `import { log } from "@warlock.js/logger"`. Skip: error hierarchy — `@warlock.js/ai/handle-ai-errors/SKILL.md`; competing libs `pino`, `winston`, `console.log`.' --- # Logging — `log` from `@warlock.js/logger` `@warlock.js/ai` does not own a logger contract. Every primitive imports the `log` singleton from [`@warlock.js/logger`](@warlock.js/logger/logger-basics/SKILL.md) directly and emits structured entries through it. Configuration — channels, levels, redaction — lives entirely on the logger. **No `ai.config({ logger })`. No per-primitive `logger:` override.** Configure once at app boot; the framework picks it up. ## Installation — configure at boot ```ts import { log, ConsoleLog, FileLog } from "@warlock.js/logger"; log.configure({ channels: [ new ConsoleLog(), new FileLog({ chunk: "daily" }), ], autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"], }); log.setMinLevel("info"); ``` That's it. Every agent / workflow / supervisor running in the process emits to the configured channels. ## Call convention — four positional args Every framework log call uses the 4-arg positional form: ```ts log.info("ai.agent", "trip.started", "agent starting trip", { tripIndex, model }); ``` - **`module`** — emitting primitive, name-suffixed (`"ai.agent."`, `"ai.workflow."`, `"ai.supervisor."`); provider adapters use `"ai.openai"` etc. - **`action`** — mirrors event names without the primitive prefix (`"trip.started"`, `"tool.called"`). - **`message`** — human-readable summary. - **`context`** — structured bag of diagnostic fields. `action` strips the prefix of the corresponding event (`agent.trip.started` → `trip.started`) so grep filters and event handlers share vocabulary. ## Level mapping | Level | Framework usage | | --- | --- | | `debug` | Internals (request/response bodies, token counts per trip) | | `info` | Milestones (agent starting, agent completed) | | `warn` | Retries, repair attempts, recoverable tool failures | | `error` | Terminal failures surfaced via `result.error` | | `success` | Tool-call success | Streaming deltas are intentionally **not** logged at token granularity — trip boundaries carry the same information at readable volume. ## What gets logged ### Agent | Action | Level | Context | |---|---|---| | `agent.starting` | `info` | inputLength, model, maxTrips | | `trip.started` | `debug` | tripIndex | | `tool.calling` | `debug` | tool name, action, tripIndex | | `tool.called` | `success` | tool name, duration, tripIndex | | `tool.failed` | `warn` | tool name, error code, tripIndex | | `repair.attempting` | `warn` | tripIndex, validation issues | | `agent.completed` | `info` | totalUsage, totalDuration, trip count | | `agent.error` | `error` | error code, message, stack | ### Workflow `workflow.starting` / `step.starting` / `step.completed` / `step.failed` / `workflow.completed` / `workflow.error`. Module is `ai.workflow.`. ### Supervisor `supervisor.starting` / `iteration.starting` / `router.deciding` / `router.decided` / `agent.starting` (per dispatched agent) / `iteration.completed` / `evaluate.verdict` / `supervisor.completed`. Module is `ai.supervisor.`. ### Provider adapter `ai.openai` (and future adapters) emit `request` (debug) and `response` (debug) per call, plus `error` on the wrapped `AIError`. ## Redaction Redaction is a `@warlock.js/logger` feature — configure once on the logger, applies to every framework log automatically. ```ts log.configure({ redact: { paths: [ "context.messages", // prompts "context.input", // user input "context.apiKey", // never log this anyway, but defense-in-depth ], }, }); ``` See [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) for the full redaction surface. ## Events vs. logs — two channels, one source - **Events** are push-model (subscribers), typed payloads, per-execution lifetime — ideal for UI streaming, SSE, metrics. - **Logs** are pull-model (written to channels), structured-string + context, persistent — ideal for grep, post-mortem. Both fire from the same internal emit so every event produces both. ## Patterns ### Silence everything in tests ```ts import { log } from "@warlock.js/logger"; beforeAll(() => log.setChannels([])); ``` ### Capture all framework log entries in a test ```ts import { log, LogChannel } from "@warlock.js/logger"; class Capture extends LogChannel { public name = "capture"; public entries: any[] = []; public log(data) { this.entries.push(data); } } const capture = new Capture(); log.setChannels([capture]); ``` See [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md) for the test patterns. ## See also - [`@warlock.js/logger/logger-basics/SKILL.md`](@warlock.js/logger/logger-basics/SKILL.md) — logger foundations - [`@warlock.js/logger/configure-logger/SKILL.md`](@warlock.js/logger/configure-logger/SKILL.md) — startup setup - [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) — redaction - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — what lands on the `error` channel ## manage-ai-stores `@warlock.js/ai/manage-ai-stores/SKILL.md` --- name: manage-ai-stores description: 'Durable orchestrator stores — ai.checkpoint.{memory,pg,redis}() for cross-turn SESSION STATE and ai.snapshot.{memory,pg,redis}() for in-flight SUPERVISOR/WORKFLOW run state. Two distinct contracts (CheckpointStore vs SnapshotStore), dev-owned pg/redis clients (no peer dep), never-auto-migrated schema(), global defaults via ai.config({defaultCheckpointStore, defaultSnapshotStore}). Triggers: `ai.checkpoint`, `ai.snapshot`, `checkpointStore`, `snapshotStore`, `CheckpointStore`, `SnapshotStore`, `CheckpointRecord`, `checkpoint.pg`, `checkpoint.redis`, `snapshot.pg`, `snapshot.redis`, `store.schema()`, `keepSnapshots`, `defaultCheckpointStore`, `defaultSnapshotStore`, `PgClientLike`, `RedisClientLike`; ''persist orchestrator sessions'', ''wire a pg checkpoint store'', ''run the store DDL'', ''checkpoint vs snapshot''; typical import `import { ai } from "@warlock.js/ai"`. Skip: orchestrator lifecycle — `@warlock.js/ai/run-orchestrator/SKILL.md`; cache-backed snapshot resume / semanticCache store — `@warlock.js/ai/persist-ai-data/SKILL.md`; competing libs `temporal`, `inngest`.' --- # Orchestrator stores — checkpoint vs snapshot `ai.orchestrator()` persists through **two distinct stores** with two distinct contracts. Confusing them is the #1 wiring mistake. | Store | Contract | Persists | Keyed by | Factories | |---|---|---|---|---| | **checkpoint** | `CheckpointStore` | cross-turn SESSION STATE (one append-only row per settled turn) | `(orchestrator_name, session_id, turn_index)` | `ai.checkpoint.{memory,pg,redis}()` | | **snapshot** | `SnapshotStore` | in-flight internal SUPERVISOR run state (for `iterate: true` mid-turn resume) | `runId` | `ai.snapshot.{memory,pg,redis}()` | - A **checkpoint** is what lets `execute()` rehydrate a session across calls — state, `turn_index`, drift `signature`, `version`, `last_route`, compaction progress, lock metadata. - A **snapshot** is what lets a crashed mid-turn `iterate: true` turn resume — it round-trips the existing `SupervisorSnapshot` envelope (the same shape the supervisor's own `snapshotStore` uses). `iterate: false` orchestrators need only a `checkpointStore`. `iterate: true` needs **both**. ## Wiring ```ts import { ai } from "@warlock.js/ai"; const orch = ai.orchestrator({ name: "support", intents, route, iterate: true, checkpointStore: ai.checkpoint.pg({ client: pgPool }), snapshotStore: ai.snapshot.pg({ client: pgPool }), // a single pg.Pool backs both }); ``` ### Global defaults ```ts ai.config({ defaultCheckpointStore: ai.checkpoint.memory(), defaultSnapshotStore: ai.snapshot.memory(), }); ``` Resolution: explicit `checkpointStore` / `snapshotStore` on the config wins, else the matching `ai.config({ default… })`, else undefined. `iterate: true` with no snapshot store resolvable throws `OrchestratorConfigError` at construction. ## The three drivers | Driver | Client | Durable | Cross-process | Fits | |---|---|---|---|---| | `memory()` | none | ❌ | ❌ | dev / tests / single-process; no resume across restarts | | `pg({ client, table?, ttl? })` | dev-supplied `pg.Pool`/`Client` | ✅ | ✅ | production with Postgres | | `redis({ client, prefix?, ttl? })` | dev-supplied `redis` client | ✅ | ✅ | production with Redis | `@warlock.js/ai` takes **NO peer dependency** on `pg` or `redis` — you install the client, build it, and pass it in via `{ client }` (anything matching `PgClientLike` / `RedisClientLike`). The store never opens or closes the connection. A single `pg.Pool` can back the cache, the checkpoint store, and the snapshot store at once. ```ts import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); ai.checkpoint.pg({ client: pool, table: "warlock_orchestrator_sessions", ttl: 86_400 }); ai.snapshot.pg({ client: pool, table: "warlock_supervisor_snapshots" }); // redis ai.checkpoint.redis({ client: redisClient, prefix: "warlock:orchestrator", ttl: 86_400 }); ai.snapshot.redis({ client: redisClient, prefix: "warlock:snapshot" }); ``` Table / prefix names must be safe SQL identifiers (`[A-Za-z_][A-Za-z0-9_]*`) — interpolated into DDL/DML, so anything outside that subset is rejected. Defaults: pg checkpoint table `warlock_orchestrator_sessions`, pg snapshot table `warlock_supervisor_snapshots`, redis prefix `warlock:orchestrator`. ## Schema is NEVER auto-migrated The framework never creates or alters tables. Each pg store exposes `schema()` returning the reference DDL — run it through YOUR migration tool once before use: ```ts const store = ai.checkpoint.pg({ client: pool }); await pool.query(store.schema()); // once, via your migration tooling ``` The memory and redis drivers return an empty `schema()` string (no backing table), so callers can treat `schema()` uniformly. ## `CheckpointRecord` — the persisted row ```ts type CheckpointRecord = { orchestrator_name: string; // PK segment 1 session_id: string; // PK segment 2 turn_index: number; // PK segment 3 — highest is live state: unknown; // post-merge session accumulator (TState) last_route: string | string[] | null; signature: string; // drift fingerprint at write time version: string | null; // config.version tag — metadata only summarized_through: number | null; // exclusive turn index compaction reached lock_acquired_at: string | null; // compaction lock metadata lock_expires_at: string | null; saved_at: string; // ISO write timestamp }; ``` Append-only from v1 — `save()` never overwrites a prior `turn_index`. `load(name, sessionId)` returns the latest row (highest `turn_index`), or `undefined` for a session the store has never seen. ## Store contract methods Both stores: `load(...)`, `save(...)`, `delete(...)`, optional `list(...)`, `schema()`. - **`list(orchestratorName, prefix?)`** (checkpoint) / **`list(prefix?)`** (snapshot) — enumerate session/run ids for the production boot-drain loop. Optional: stores that can't enumerate omit it. - The orchestrator's **`keepSnapshots`** retention policy lives on the orchestrator config, NOT the store — the orchestrator calls the pg store's `prune()` after a successful `save` when `keepSnapshots` is a finite number; `"all"` skips pruning. ## Boot-drain pattern On startup, resume any session whose last turn was interrupted mid-flight: ```ts const sessions = await checkpointStore.list?.(orch.name) ?? []; for (const sessionId of sessions) { await orch.resume(sessionId); // null when nothing in flight — harmless } ``` ## Distinct from `@warlock.js/cache` snapshot resume A bare `ai.supervisor()` / `ai.workflow()` uses a `snapshotStore` for `resume(runId)`. That `SnapshotStore` was promoted from the historical `@warlock.js/cache` `CacheDriver` path. ⚠ The CacheDriver overload is deprecated for one minor — new code wires `ai.snapshot.*` stores. See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) for the supervisor/workflow side and the cache-backed semantic cache. ## See also - [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md) — the consumer of these stores - [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — supervisor/workflow snapshot resume + the SnapshotStore migration - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `OrchestratorDriftError` / `OrchestratorConfigError` ## manage-prompts `@warlock.js/ai/manage-prompts/SKILL.md` --- name: manage-prompts description: 'Unified prompt registry — ai.prompts: one process-wide store of named, versioned systemPrompt(...) builders keyed by name@version. Register by giving a prompt a meta.name (auto-registers), resolve by get(name) / resolve(name, versionOrTag, placeholders) / the inline name@selector form, bulk-register with define(name, versions), pin tags with tag(name, tag, version), compare with diff(name, from, to), round-trip with export() / import(snapshot), and quality-check with a unified validate(target, options) (deterministic missing-placeholder check + optional Nova-safe LLM-as-judge with verdict caching). Compose registered prompts into new ones with systemPrompt().merge(name, { fromVersion }) — provenance recorded in meta.composedFrom. ai.prompt is now a thin FACADE over ai.prompts (BREAKING vs the old standalone registry). Triggers: `ai.prompts`, `ai.prompt`, `PromptsManagerContract`, `PromptsManagerEntry`, `SystemPromptContract`, `SystemPromptMeta`, `SystemPromptMergeOptions`, `PromptsValidateOptions`, `PromptValidationResult`, `PromptValidateTarget`, `PromptTemplateVersion`, `PromptDiff`, `ExportedRegistry`, `defaultPromptsManager`, `prompts()`, `promptKey`, `meta`, `name`, `version`, `composedFrom`, `fromVersion`, `register`, `create`, `get`, `has`, `list`, `versions`, `resolve`, `define`, `tag`, `validate`, `diff`, `export`, `import`, `merge`, `judge`, `judgeCache`, `criteria`; ''register a prompt by name'', ''resolve a prompt by name@version or tag'', ''pin a production tag to a prompt version'', ''diff two prompt versions'', ''export / import the prompt registry'', ''validate a prompt for missing placeholders'', ''validate a prompt against my own criteria / rules'', ''merge a registered prompt into another''; typical import `import { ai } from "@warlock.js/ai"`. Skip: composing a single prompt from persona + instruction blocks (the builder itself) — `@warlock.js/ai/write-system-prompt/SKILL.md`; runtime loadable skill bodies — `@warlock.js/ai/use-runtime-skills/SKILL.md`; eval scoring of agent outputs — `@warlock.js/ai/eval-datasets-and-ci/SKILL.md`; competing libs `langfuse` (direct), `promptfoo`.' --- # `ai.prompts` — the unified prompt registry `ai.prompts` is ONE process-wide registry of named, versioned `systemPrompt(...)` builders keyed by `name@version`. A `systemPrompt(input, { name })` (or any `.meta({ name })` rename) auto-registers here; `ai.prompts.get(name)` / `.resolve(name)` read them back; `systemPrompt().merge(name)` folds a registered prompt into a new one. There is exactly **one storage shape** behind the whole prompt surface — a `SystemPromptContract` keyed by `name@version` — and `ai.prompt(...)` is now a thin facade over it (see the migration note below). ```ts import { ai } from "@warlock.js/ai"; // Register: any named systemPrompt auto-registers in ai.prompts. ai.systemPrompt("You are support for {{product}}.", { name: "support" }); // Resolve back — latest version, or a version / pinned tag. ai.prompts.get("support"); // → the SystemPromptContract ai.prompts.resolve("support", undefined, { product: "Warlock" }); // → final string ``` `ai.prompts` is the process-wide default (`defaultPromptsManager()`). For an **isolated** registry (parallel test suites, multi-tenant apps) call the `prompts()` factory — same `PromptsManagerContract`, its own store, no global side effects. ## Identity — `SystemPromptMeta` (`meta.name` / `version` / `description` / `required` / `composedFrom`) A prompt's identity rides on its `meta`. Read it with the no-argument accessor; update it immutably with the one-argument form: ```ts const base = ai.systemPrompt("You are support.", { name: "support", version: "1", description: "Tier-1 support persona.", required: ["product"], }); base.meta(); // → { name: "support", version: "1", description, required } const v2 = base.meta({ version: "2" }); // new builder, shallow-merged meta; original untouched ``` - **`name`** — when present, the prompt auto-registers in `ai.prompts` under `name@version`. Anonymous prompts (no `name`) are never registered. - **`version`** — free-form label (`"1"`, `"2025-draft"`). Defaults to the **next integer** for that name when omitted. - **`description`** — human-readable purpose (carried through `export`). - **`required`** — placeholder keys callers must supply; `validate()` reads them. - **`composedFrom`** — deterministic source labels a prompt was merged from (e.g. `["base@2", "global@1"]`). No random suffixes — the same merge always yields the same labels. ## Register / resolve — `register` / `get` / `resolve` / `has` / `list` / `versions` ```ts const registry = ai.prompts; // or prompts() for an isolated one registry.register(ai.systemPrompt("You are support.", { name: "support" })); registry.versions("support"); // ["1"] — version derived as next integer registry.get("support"); // latest SystemPromptContract registry.get("support@1"); // inline name@selector registry.resolve("support", "1", { product: "Warlock" }); // pick version + render in one call registry.has("support"); // boolean registry.list(); // every registered name, first-seen order ``` - **Version selection** — `get(name)` / `resolve(name)` return the **latest** by insertion order; pass a version label, a pinned tag, or fold it into the first arg as `name@selector` (`get("support@1")`, `resolve("support@production")`). - **Duplicates** — re-registering the same `name@version` throws `InvalidRequestError` **unless** the content is byte-identical (idempotent re-registration is a no-op). - **Unknown name / version / tag** → `InvalidRequestError`. - `register()` throws if the prompt has no `meta.name`. ## `create()` — build + register in one entry point `ai.prompts.create(input?, meta?)` is a documented alias of `ai.systemPrompt(...)` — identical input forms (no arg → empty builder; a string → one instruction; an array of blocks → verbatim). Pass `meta.name` to auto-register, so authoring and lookup read side-by-side: ```ts ai.prompts.create("You are support for {{product}}.", { name: "support" }); ai.prompts.resolve("support", undefined, { product: "Warlock" }); ``` ## `define()` — bulk-register many versions ```ts ai.prompts.define("agent", [ { version: "1", template: "You are v1." }, { version: "2", template: [ai.persona("You are Alex."), ai.instruction("Be concise.")] }, ]); ``` A `PromptTemplateVersion`'s `template` is a raw string (wrapped into one instruction block) or an explicit ordered block list (verbatim). Versions register **oldest-first** in array order; the same duplicate / idempotency rule applies per `name@version`. Returns the manager for chaining. ## `tag()` — pin a moving label to a version ```ts ai.prompts.tag("agent", "production", "2"); // pin "production" → version 2 ai.prompts.get("agent", "production"); // resolves through the tag ai.prompts.resolve("agent", "production"); ai.prompts.get("agent@production"); // inline form ``` Re-pinning an existing tag moves it. An unknown name / version throws `InvalidRequestError`. Tags survive `export` / `import`. ## `validate()` — unified deterministic + optional LLM-judge ```ts const report = await ai.prompts.validate("support", { placeholders: { product: "Warlock" }, // values you intend to supply declare: ["language"], // extra keys to treat as known judge: judgeModel, // optional — turns on the LLM-as-judge pass criteria: [ // optional — YOUR rules, replaces the built-in rubric "Addresses the user by {{name}}", "Never gives medical advice", "Stays under 200 words", ], }); report.ok; // true iff no required placeholder is missing (DETERMINISTIC verdict alone) report.missing; // placeholder keys referenced with no default, unsupplied, undeclared report.score; // 0..1 — present ONLY when a judge ran and produced a usable verdict report.issues; // advisory judge reasons / a degrade note — present only when a judge was supplied ``` - **Always** runs the deterministic check: every `{{key}}` with no inline default that is neither supplied (`placeholders`), declared (`declare`), nor in the prompt's `meta.required` lands in `missing`; `ok` is `true` iff `missing` is empty. - **`judge`** adds a **Nova-safe** LLM-as-judge quality pass — it **never throws** and degrades to an `issues` note (leaving `score` undefined) on failure, so a flaky judge can **never flip `ok`**. - **`criteria`** (a string or a list of short rules) grades the prompt against **your own rules** instead of the built-in quality rubric — `score` / `issues` then reflect your criteria (a failed rule is named in `issues`). Only used when `judge` is also set; folded into the `judgeCache` key so different rules re-run. Still advisory — never flips `ok`. - **`target`** is a registered name (or `name@selector`), a `SystemPromptContract` instance, or a raw prompt string. - **`judgeCache`** (per-call or via the `prompts({ judgeCache })` factory option) memoizes judge verdicts by a content hash of the resolved body + the judge model id — a structural `{ get, set }` subset of `@warlock.js/cache`'s `CacheDriver`, so the cache package stays a strictly **optional** peer. `systemPrompt().validate(options?)` is the per-builder sugar — `ai.prompts.validate(this, options)` under the hood, same result shape. ## `diff()` — block-level version diff ```ts const diff = ai.prompts.diff("agent", "1", "2"); diff.identical; // true when both versions have identical blocks in identical order diff.added; // blocks in `to` not at the same position in `from` diff.removed; // blocks in `from` not at the same position in `to` diff.changed; // [{ from, to }] — same position, type/text changed ``` Blocks are matched **positionally**. Unknown name / version → `InvalidRequestError`. ## `export()` / `import()` — portable JSON round-trip ```ts const snapshot = ai.prompts.export(); // ExportedRegistry — every name, version, pinned tag, description/required otherRegistry.import(snapshot); // rehydrate (same duplicate / idempotency rule; tags restored) ``` Each version flattens to `{ type, text }` blocks so the registry round-trips without live builder instances — commit a snapshot, ship it, restore it elsewhere. ## Compose registered prompts — `systemPrompt().merge(name, { fromVersion })` `merge` folds another prompt's blocks into a new builder (persona **replaces**, instructions **append**) and records `meta.composedFrom`: ```ts ai.systemPrompt("Always answer in {{language|English}}.", { name: "global", version: "1" }); const supportPrompt = ai.systemPrompt("You are support for {{product}}.") .merge("global", { fromVersion: "1" }); // fold the registered prompt by name supportPrompt.meta()?.composedFrom; // ["…", "global@1"] — deterministic provenance ``` `merge` accepts three source forms: a pre-built block, another `SystemPromptContract`, or a **registered name** resolved from `ai.prompts` (latest version unless `options.fromVersion` selects another — an unknown name / version throws `InvalidRequestError`). ## `ai.prompt(...)` — now a thin facade (⚠ breaking vs the old registry) `ai.prompt` has **two** call forms, both backed by the unified manager — there is no longer a separate prompt store: ```ts // (a) Resolve a globally-registered prompt from ai.prompts by name. ai.systemPrompt("You are support.", { name: "support" }); const sp = ai.prompt("support"); // → SystemPromptContract (latest) const v1 = ai.prompt("support", "1"); // → a specific version / pinned tag // (b) Build an ISOLATED legacy-shaped registry (PromptRegistryContract). const reg = ai.prompt({ prompts: [{ name: "summarizer", versions: [{ version: "1", template: "Summarize: {{text}}" }] }], }); const resolved = reg.resolve("summarizer", { placeholders: { text } }); resolved.toSystemPrompt(); // drop-in for ai.agent({ systemPrompt }) ``` **⚠ Migration.** Before unification, `ai.prompt(...)` only built a standalone, self-contained registry with its **own private** storage. It now: 1. Adds the **string overload** `ai.prompt(name, versionOrTag?)` → resolves from the shared `ai.prompts` manager. (New capability — `ai.prompt("x")` used to be a type error.) 2. Backs the **options form** (`ai.prompt({ ... })` → `PromptRegistryContract`) by an internal `PromptsManagerContract`, so its storage shape and validation primitives are now the unified ones. The legacy method surface (`register` / `add` / `versions` / `resolve` / `validate` / `sync` + the `{ score, notes }` report shape) is **unchanged**, and each `ai.prompt({ ... })` call still returns its **own isolated** registry — no shared global state. If you only ever called `ai.prompt({ ... })` and used the returned registry, **no code change is needed**. The new behavior is additive: prefer `ai.prompts` (the unified manager) for new code; reach for `ai.prompt({ ... })` only when you want the legacy `ResolvedPrompt` / `toSystemPrompt()` ergonomics or the optional Langfuse sync. The legacy facade's reference — `register` / `add` / `resolve(name, { version, placeholders })` / `validate` (`{ score, notes }`) / `sync()` (lazy `langfuse` peer) — is documented inline in `src/prompt/prompt.ts`. ## See also - [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) — the `systemPrompt()` / `persona()` / `instruction()` builder, `.meta()`, and `merge()` this registry stores and composes - [`@warlock.js/ai/refine-prompts/SKILL.md`](@warlock.js/ai/refine-prompts/SKILL.md) — `systemPrompt().refined({ model, criteria, store })`, the prompt compiler; register its `refinePrompt()` output as a next version to `diff` original vs refined - [`@warlock.js/ai/eval-datasets-and-ci/SKILL.md`](@warlock.js/ai/eval-datasets-and-ci/SKILL.md) — the eval `judge` scorer `validate()`'s LLM pass reuses - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — wiring a resolved prompt into an agent, plus the judge-safe agent preset (`ai.agent.judge`) ## observe-ai-flows `@warlock.js/ai/observe-ai-flows/SKILL.md` --- name: observe-ai-flows description: 'The core Observer seam — a generic, tool-agnostic observability hook every flow routes its completed ExecutionReport through. Covers the per-flow `observe?: boolean | Observer` option on ai.agent / workflow / supervisor / team, the global registry (registerObserver / getObservers / setObserveAll / isObserveAll / clearObservers), resolveObservers / notifyObservers resolution, the opt-in AgentConfig.captureMessages → AgentReport.messages full-history capture, the onConfigApplied dependency-inversion seam, and that @warlock.js/ai-panoptic is the batteries-included Observer. Triggers: `Observer`, `observe`, `registerObserver`, `getObservers`, `setObserveAll`, `isObserveAll`, `clearObservers`, `resolveObservers`, `notifyObservers`, `FlowObserveOption`, `ExecutionReport`, `captureMessages`, `AgentReport.messages`, `CapturedMessage`, `onConfigApplied`, `observeAll`; ''observe an agent run'', ''send finished reports to a collector'', ''capture the full message history'', ''observe every flow by default'', ''wire panoptic / tracing''; typical import `import { ai, registerObserver } from "@warlock.js/ai"`. Skip: structured logging of events — `@warlock.js/ai/log-ai-calls/SKILL.md`; reading the report tree shape (trips / children) — `@warlock.js/ai/run-ai-agent/SKILL.md`; per-call cost / usage rollup — `@warlock.js/ai/handle-ai-errors/SKILL.md`. The batteries-included Observer is the `@warlock.js/ai-panoptic` package.' --- # The `Observer` seam — generic, tool-agnostic observability Core defines a structural `Observer` and a tiny registry; it never imports any observability package (panoptic, OTel, Langfuse, …). A flow that resolves to "observed" hands its completed `ExecutionReport` to every registered observer. An observability tool **implements `Observer` and registers itself**, so `observe: true` / observe-all route reports without coupling core to the tool — the dependency inversion that keeps the two sides decoupled. ```ts export interface Observer { collect(report: ExecutionReport): void | Promise; } ``` `collect` may be sync or async — the flow awaits it. A throw is **swallowed** by the flow (never breaks the run), mirroring the existing `onUsage` / `onComplete` hooks. ## Per-flow `observe` option `observe?: boolean | Observer` (`FlowObserveOption`) is accepted on **`ai.agent`, `ai.workflow`, `ai.supervisor`, and `ai.team`** (a team forwards it verbatim to the supervisor it desugars into): ```ts const collector: Observer = { collect(report) { exporter.send(report); } }; ai.agent({ model, observe: true }); // → the globally registered observers, even if observe-all is off ai.agent({ model, observe: false }); // → opt out entirely, even when observe-all is on ai.agent({ model, observe: collector }); // → a flow-LOCAL collector; only this flow's report, only to it ai.agent({ model }); // → undefined: follow the global observe-all flag ``` Resolution (`resolveObservers(observe)`): - `false` → `[]` (opted out). - `true` → the globally registered observers. - an `Observer` object → just that one (flow-local; the global observers are skipped). - `undefined` → the global observers when observe-all is on, otherwise `[]`. `notifyObservers(observe, report)` routes a completed report to each resolved observer, awaiting each `collect` (so async exporters finish before the flow returns) and swallowing any throw. The object form is typed as the structural `Observer` (NOT a panoptic-specific type), so a panoptic flow-local collector — which implements `Observer` — can be passed directly. ## The global registry ```ts import { registerObserver, getObservers, setObserveAll, isObserveAll, clearObservers, } from "@warlock.js/ai"; registerObserver(collector); // an observability tool registers ONE collector when its config is applied getObservers(); // read-only snapshot of the registered observers (do not mutate) setObserveAll(true); // "observe every flow by default" — flows without their own `observe` get observed isObserveAll(); // read the flag (default false — opt-in observability) clearObservers(); // test-only: reset observers + the observe-all flag for spec isolation ``` `observeAll` defaults to `false` (opt-in). A flow that never sets `observe` is observed **only** when observe-all is on; individual flows still opt out with `observe: false`. ## Full-history capture — `captureMessages` → `AgentReport.messages` Off by default. When `ai.agent({ captureMessages: true })` is set, the agent normalizes the real assembled turn array onto `AgentReport.messages` as a `CapturedMessage[]`: ```ts const { report } = await ai.agent({ model, tools, captureMessages: true }).execute("Go"); report.messages; // CapturedMessage[] — every role (system/user/assistant/tool), every trip ``` A `CapturedMessage` is a JSON-safe projection: `{ role, content, toolCalls?, toolCallId? }` — `content` is always a string (tool results stringified), assistant turns that triggered tools carry `toolCalls`, tool-result turns carry the `toolCallId` they answer. Unlike `trips[].input` (which stubs non-first trips with `"[tool results]"`), this preserves the **real** turn array. Omitted ⇒ the field is **absent** and the report is byte-for-byte as before. Opt-in because messages can be large and sensitive (full prompts, tool inputs/outputs) — and **required for panoptic full-history capture**. ## Callback / run-step sub-agents nest in the report tree The `ExecutionReport` an observer receives reflects **full** lineage: a supervisor / team / orchestrator callback, OR a workflow `run` step, that calls `agent.execute()` directly auto-nests `→ agent → tool` (via an ambient `RunFrame`), so usage / cost roll up and panoptic renders the sub-agent under its enclosing node instead of as a lone `$0` span AND a disconnected second top-level trace. No observer-side change is needed — the tree arrives already nested. A team's root span carries `type: "team"` (a first-class `ReportType`, not `"supervisor"`), so observers can distinguish, group, and label team runs as their own type. See [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) and [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md). ## The config seam — `onConfigApplied` An observability tool reacts to its own augmented config slot without core importing it. Core lets tools attach an opaque slot (e.g. `panoptic?`) via declaration merging on `AIConfig`, then fires registered listeners after each `ai.config(...)` merge: ```ts import { onConfigApplied, getAIConfig } from "@warlock.js/ai"; onConfigApplied((config) => applyPanopticConfig(config.panoptic)); // react on every config merge applyPanopticConfig(getAIConfig().panoptic); // catch a pre-set config ``` A misbehaving listener's throw is swallowed (same swallow-on-throw discipline as the observer hooks). This mirrors the `Observer` registry's dependency inversion: a tool flips `setObserveAll(true)` and calls `registerObserver(...)` from inside its `onConfigApplied` listener. ## The batteries-included Observer `@warlock.js/ai-panoptic` is the shipped, full-featured `Observer` — install it, configure it via `ai.config({ panoptic })`, and it registers its collector + (optionally) flips observe-all for you. Core stays dependency-free; this skill documents the seam panoptic plugs into. ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the `AgentReport` / `ExecutionReport` tree (`trips`, `children`) an observer receives - [`@warlock.js/ai/log-ai-calls/SKILL.md`](@warlock.js/ai/log-ai-calls/SKILL.md) — event-level structured logging (vs. report-level observation) - [`@warlock.js/ai/run-ai-team/SKILL.md`](@warlock.js/ai/run-ai-team/SKILL.md) — a team inherits `observe` through the supervisor it forwards to ## persist-ai-data `@warlock.js/ai/persist-ai-data/SKILL.md` --- name: persist-ai-data description: 'Persistence delegated to @warlock.js/cache — workflow + supervisor snapshot resume via snapshotStore (4.3.0: now a SnapshotStore from ai.snapshot.*, ⚠ moved off raw CacheDriver), semantic cache + memory via vector-capable CacheDriver, global defaults via ai.config({defaultStore}) + ai.config({defaultSnapshotStore}). Covers drift detection + three recovery paths. Triggers: `ai.config`, `defaultStore`, `defaultSnapshotStore`, `snapshotStore`, `ai.snapshot`, `wf.resume`, `supervisor.resume`, `WorkflowSnapshot`, `SupervisorSnapshot`, `WorkflowDriftError`, `SupervisorDriftError`, `force: true`; ''resume a workflow run'', ''configure snapshot store'', ''handle signature drift'', ''wire pg vector cache''; typical import `import { ai } from "@warlock.js/ai"`. Skip: orchestrator checkpoint/snapshot store factories — `@warlock.js/ai/manage-ai-stores/SKILL.md`; cache driver catalog — `@warlock.js/cache/cache-basics/SKILL.md`; competing libs `temporal`, `inngest`.' --- # Persistence — `@warlock.js/cache` everywhere `@warlock.js/ai` owns no persistence primitives. Anything that needs durable state — supervisor / workflow snapshot resume, semantic cache, future memory — accepts a `CacheDriver` from `@warlock.js/cache`. The cache package ships memory / lru-memory / file / redis / pg drivers; pg adds optional `pgvector` for similarity retrieval. ## The big picture ``` ┌──────────────┐ ┌─────────────────────────┐ │ ai.config │ ───▶ │ @warlock.js/cache │ │ defaultStore │ │ CacheDriver │ └──────────────┘ │ (memory|redis|pg|...) │ └────────────▲────────────┘ │ ┌─────────────────────────────┼─────────────────────────────┐ ┌──────────────┐ ┌────────────────────┐ ┌──────────────────┐ │ supervisor │ │ workflow │ │ semanticCache │ │ snapshotStore│ │ snapshotStore │ │ store (vector) │ └──────────────┘ └────────────────────┘ └──────────────────┘ ``` ## Resolution order — two separate defaults ``` // semantic cache + memory (CacheDriver): options.store ?? ai.config({ defaultStore }) ?? undefined // supervisor / workflow / orchestrator snapshots (SnapshotStore): options.snapshotStore ?? ai.config({ defaultSnapshotStore }) ?? undefined ``` `defaultStore` (a `CacheDriver`) and `defaultSnapshotStore` (a `SnapshotStore`) are independent — set whichever the consumer needs. When the relevant one is unset: - **Snapshot consumers** silently skip writes and throw on `resume()`. - **Semantic cache / memory** throws at construction. ## `ai.config({ defaultStore })` — set once at boot ```ts import { ai } from "@warlock.js/ai"; import { cache } from "@warlock.js/cache"; ai.config({ defaultStore: cache.driver("redis", { client: redisClient }), }); ``` Every consumer that doesn't supply its own `store` / `snapshotStore` picks this up. Per-declaration overrides win. ## Picking a driver | Driver | KV | TTL | Tags | `similar()` | Fits | |---|---|---|---|---|---| | `memory` / `lru-memory` | ✅ | ✅ | ✅ | ✅ (brute force) | Dev / tests | | `file` | ✅ | ✅ | ✅ | ❌ | Single-process persistence | | `null` | no-op | no-op | no-op | `[]` | Test isolation | | `redis` | ✅ | ✅ | ✅ | (RediSearch, separate phase) | Production KV + future similarity | | `pg` | ✅ | ✅ | ✅ | ✅ (pgvector) | Production semantic cache | Brute-force memory drivers carry an `O(N)` similarity scan — fine up to a few thousand entries. ## Snapshot resume — workflow + supervisor > ⚠ **BREAKING (4.3.0): supervisor + workflow snapshot persistence moved `CacheDriver` → `SnapshotStore`.** A `snapshotStore` is now a `SnapshotStore` built with `ai.snapshot.{memory,pg,redis}()`, not a raw `cache.driver(...)`. The framework still ships a deprecated `CacheDriver` overload for ONE minor so existing wiring keeps working, but new code uses the dedicated store factories. The `defaultSnapshotStore` resolution is via `ai.config({ defaultSnapshotStore })` (a `SnapshotStore`), separate from `defaultStore` (a `CacheDriver`, still used for `semanticCache` + memory). See [`@warlock.js/ai/manage-ai-stores/SKILL.md`](@warlock.js/ai/manage-ai-stores/SKILL.md). ### Wiring (new) ```ts import { ai } from "@warlock.js/ai"; ai.config({ defaultSnapshotStore: ai.snapshot.redis({ client }) }); const wf = ai.workflow({ name: "ticket-processor", steps: [...], // snapshotStore optional — falls back to ai.config({ defaultSnapshotStore }) }); const sup = ai.supervisor({ name: "support-team", router: routerAgent, intents: { triage, billing, resolver }, // explicit override when this primitive needs a different store snapshotStore: ai.snapshot.pg({ client: pgPool, table: "support_runs" }), }); ``` The `SnapshotStore` is generic over its snapshot shape — it defaults to `SupervisorSnapshot`, and the workflow engine parameterizes it with `WorkflowSnapshot`; the only structural requirement is a `runId` string. `ai.snapshot.memory()` for dev/tests, `ai.snapshot.{pg,redis}()` for production (dev-owned client, never-auto-migrated `schema()`). ### Snapshot shapes ```ts type WorkflowSnapshot = { runId: string; workflowName: string; signature: string; // structural fingerprint version?: string; input: unknown; state: Record; steps: Record; next: string | null; status: "running" | "completed" | "failed" | "cancelled"; startedAt: string; savedAt: string; }; type SupervisorSnapshot = { runId: string; supervisorName: string; signature: string; input: string | Record; // SupervisorInput iteration: number; // last *completed* iteration; -1 before any settle snapshots: IterationSnapshot[]; status: "running" | "completed" | "failed" | "cancelled"; startedAt: string; savedAt: string; }; ``` ### Checkpoint rules - Workflow: snapshot after every step settles. Parallel groups checkpoint atomically. - Supervisor: snapshot after every iteration. Plus once on final completion / cancel / fail. - Mid-step / mid-iteration crash resumes from the last completed checkpoint — partial work is **not** persisted. - **Idempotency is the user's responsibility.** Steps and agents may re-run on resume. ## Fresh run vs. resume ```ts const result = await wf.execute({ input, runId: "ticket-123" }); const result = await wf.resume("ticket-123"); await sup.execute("urgent", { runId: "support-7" }); await sup.resume("support-7"); ``` Resume reads the snapshot, rehydrates state, continues from the snapshot's `next`. ## Signature drift detection `signature` is a structural fingerprint computed at construction. On `resume()`, current signature is compared to the snapshot's. Mismatch throws `WorkflowDriftError` / `SupervisorDriftError` **without executing**: ```ts { code: "WORKFLOW_DRIFT", savedSignature: "abc123…", currentSignature: "def456…", runId: "ticket-123", completedSteps: ["fetch", "extract"], pendingStep: "classify", } ``` ## Recovery paths Three choices when drift is detected: 1. **Discard** — safest when the shape genuinely changed: ```ts await store.remove("ticket-123"); await wf.execute({ input, runId: "ticket-123" }); ``` 2. **Force resume** — escape hatch for trivial edits you know are safe: ```ts await wf.resume("ticket-123", { force: true }); ``` 3. **Manual migration** — for changes you can mechanically translate: ```ts const snapshot = await store.get("ticket-123"); if (snapshot) { snapshot.steps.newName = snapshot.steps.oldName; delete snapshot.steps.oldName; snapshot.signature = wf.signature; await store.set("ticket-123", snapshot); await wf.resume("ticket-123"); } ``` ## Semantic cache ```ts ai.config({ defaultStore: cache.driver("pg", { client: pgPool, vector: { dimensions: 1536, index: "hnsw" }, }), }); const myAgent = ai.agent({ model, middleware: [ ai.middleware.semanticCache({ embedder: openai.embedder({ name: "text-embedding-3-small" }), threshold: 0.95, ttlMs: 60 * 60 * 1000, }), ], }); ``` The driver must support `similar()`. Without similarity → `CacheUnsupportedError`. See [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md). ## See also - [`@warlock.js/ai/manage-ai-stores/SKILL.md`](@warlock.js/ai/manage-ai-stores/SKILL.md) — `ai.snapshot.*` + `ai.checkpoint.*` store factories, schema(), drivers - [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — `snapshotStore` + `resume()` - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — same on supervisor - [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) — `semanticCache` middleware - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — drift errors - [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — driver catalog ## pick-ai-provider `@warlock.js/ai/pick-ai-provider/SKILL.md` --- name: pick-ai-provider description: 'Choose an AI provider adapter — @warlock.js/ai-openai (shipped, also handles OpenRouter / Azure via baseURL), @warlock.js/ai-anthropic, @warlock.js/ai-bedrock, @warlock.js/ai-google, @warlock.js/ai-ollama — plus cost truth: ModelPricing (per-1M tokens), Usage cost breakdown, the cachedTokens / cacheWriteTokens / reasoningTokens channels, and capability flags. Triggers: `OpenAISDK`, `SDKAdapterContract`, `ModelContract`, `ModelPricing`, `ModelCapabilities`, `sdk.model`, `sdk.embedder`, `capabilities.vision`, `capabilities.structuredOutput`, `capabilities.reasoning`, `capabilities.promptCaching`, `pricing`, `Usage.cost`, `cachedTokens`, `cacheWriteTokens`, `reasoningTokens`, `reasoning.effort`, `cacheControl`, `baseURL`, `provider: "openrouter"`; ''pick a provider'', ''openai vs openrouter'', ''does this model support vision/reasoning'', ''configure pricing'', ''how much did reasoning cost'', ''prompt cache tokens''; typical import `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: agent factory — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs raw `openai`, `@anthropic-ai/sdk`, `@aws-sdk/client-bedrock-runtime`.' --- # Pick an AI provider adapter `@warlock.js/ai` is provider-agnostic. Concrete adapters live in sibling packages and follow the same `SDKAdapterContract`. Pick by which provider(s) your app talks to and which capabilities the model needs. ## Available adapters | Package | Status | Notes | | --- | --- | --- | | `@warlock.js/ai-openai` | ✅ Shipped | OpenAI + any OpenAI-compatible gateway (OpenRouter, Together.ai, etc.) | | `@warlock.js/ai-anthropic` | ✅ Shipped | Native Claude API (Opus / Sonnet / Haiku) | | `@warlock.js/ai-bedrock` | ✅ Shipped | AWS Bedrock — Converse API + Titan embeddings | | `@warlock.js/ai-google` | ✅ Shipped | Gemini direct via `@google/genai`, native batch embeddings | | `@warlock.js/ai-ollama` | ✅ Shipped | Local models via the official `ollama` client | All five first-party adapters share the same `SDKAdapterContract`, so switching providers is a one-line change at the model construction site. `ai-openrouter` is intentionally deferred — use `ai-openai` with a `baseURL` pointed at OpenRouter. ## Decision tree - **Default first choice:** `@warlock.js/ai-openai` direct to OpenAI. Best support, predictable behavior, native structured-output, native vision on `gpt-4o*`, embeddings, streaming. - **Need many models / cost arbitrage:** `@warlock.js/ai-openai` against OpenRouter. Same code, different `baseURL` + `provider: "openrouter"` on the SDK. - **Need native Claude features:** `@warlock.js/ai-anthropic` — Opus / Sonnet / Haiku via the native Messages API. - **Need local / self-hosted models:** `@warlock.js/ai-ollama`, or a local OpenAI-compatible gateway via `ai-openai`. - **Need AWS Bedrock pricing / compliance:** `@warlock.js/ai-bedrock` — Converse API + Titan embeddings. - **Need Gemini:** `@warlock.js/ai-google` — Gemini direct via `@google/genai`. ## The adapter contract ```ts interface SDKAdapterContract { model(config): ModelContract; // chat completions / tool calls / structured output count(text, model?): Promise; // token counting embedder?(config): EmbedderContract; // optional — not every provider supports embeddings } ``` Adapters are classes — `new OpenAISDK({ apiKey })`, `new AnthropicSDK({ apiKey })`. They expose: - `model({ name, ...options })` — returns a `ModelContract`. The provider label lives on the returned `ModelContract.provider` (`"openai"`, `"openrouter"`, …), not on the SDK. - `count(text, model?)` — provider-appropriate token count. - `embedder({ name })` — text-to-vector. Optional; check `typeof sdk.embedder === "function"` before calling. The `ModelContract.capabilities` field declares what the model supports — all flags optional (absent = treat as `false`): ```ts type ModelCapabilities = { structuredOutput?: boolean; // native response_format: json_schema support? vision?: boolean; // can accept image attachments? reasoning?: boolean; // forwards ModelCallOptions.reasoning (effort / thinking budget)? promptCaching?: boolean; // honors cacheControl breakpoints + reports cache token channels? audio?: boolean; // can accept audio ContentPart input? pdf?: boolean; // can accept PDF / document ContentPart input? }; ``` The framework reads `capabilities` to fail loud upfront — e.g. passing `attachments: [...]` to a non-vision model throws at the boundary instead of failing mid-trip; reasoning / cacheControl options are silently skipped when the adapter doesn't declare support, rather than sent as unsupported params. ## OpenAI adapter — usage ```ts import { OpenAISDK } from "@warlock.js/ai-openai"; // Direct OpenAI const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY!, pricing: { "gpt-4o-mini": { input: 0.15, output: 0.6, cachedInput: 0.075 }, "gpt-4o": { input: 5.0, output: 15.0 }, }, }); const agent = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) }); ``` ### Via OpenRouter (cost arbitrage, many providers) ```ts const openrouter = new OpenAISDK({ apiKey: process.env.OPENROUTER_API_KEY!, baseURL: "https://openrouter.ai/api/v1", provider: "openrouter", // labels reports correctly }); const agent = ai.agent({ model: openrouter.model({ name: "anthropic/claude-3.5-sonnet" }) }); ``` Same `OpenAISDK` class, different `baseURL`. Reports label the provider via the `provider` field for downstream metrics. ### Per-model overrides ```ts const openai = new OpenAISDK({ apiKey }); // Override capabilities for a custom or fine-tuned model const customModel = openai.model({ name: "my-org/custom-gpt-4-finetuned", vision: true, // override capabilities.vision structuredOutput: true, pricing: { input: 1.0, output: 3.0 }, // per-model pricing (wins over SDK registry) }); ``` ## Cost truth — pricing + token channels `ModelPricing` is **USD per 1,000,000 tokens** (the industry-standard unit), declared at two optional sites — `SDK.pricing` (registry keyed by model name) and `model({ pricing })` (per-model override, wins). Resolution: per-model > SDK registry > undefined (no cost computed). ```ts type ModelPricing = { input: number; // required — USD / 1M input tokens output: number; // required — USD / 1M output tokens cachedInput?: number; // prompt-cache READ rate; falls back to `input` cachedOutput?: number; // cache-WRITE rate (Anthropic premium); falls back to `output` reasoning?: number; // reasoning/thinking-token rate; falls back to `output` }; ``` Configure it and every report carries `Usage.cost` — a per-channel breakdown captured at emit time as a historical fact (stored reports stay accurate after the upstream table changes): ```ts const { usage } = await ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) }).execute("hi"); usage.cost; // { input, output, cachedInput?, cachedOutput? } — USD per channel // single scalar total: sum the populated fields, treating undefined as 0. ``` `usage.cost` is `undefined` when no pricing is available — honest absence over false zero. Aggregators merge only defined fields, so one unpriced child never erases a priced sibling's cost. ### Token channels (`Usage`) — what each adapter reports Beyond `input` / `output` / `total`, `Usage` carries optional sub-channels (undefined when the provider doesn't meter them): | Channel | Meaning | Provider source | |---|---|---| | `cachedTokens` | subset of `input` served from prompt cache (READ hit) | OpenAI `prompt_tokens_details.cached_tokens`, Anthropic `cache_read_input_tokens` | | `cacheWriteTokens` | input tokens WRITTEN to the cache this call | Anthropic `cache_creation_input_tokens` (OpenAI does not write-bill) | | `reasoningTokens` | subset of `output` for internal reasoning/thinking | OpenAI `completion_tokens_details.reasoning_tokens`, Anthropic extended-thinking | ### Driving cache + reasoning per call `ModelCallOptions` exposes vendor-neutral controls the agent forwards only when `capabilities` allows: ```ts await model.complete(messages, { reasoning: { effort: "high", maxTokens: 8_000 }, // effort → OpenAI reasoning_effort; maxTokens → Anthropic thinking budget cacheControl: { breakpoints: 1 }, // WRITE breakpoint → Anthropic cache_control markers }); ``` Read-side cache accounting (`Usage.cachedTokens`) works WITHOUT `cacheControl` — it only controls WRITE placement. Adapters whose `capabilities.reasoning` / `.promptCaching` is absent ignore these rather than forwarding unsupported params. ## Embeddings OpenAI ships the first embedder: ```ts const embedder = openai.embedder({ name: "text-embedding-3-small" }); const { vector } = await embedder.embed("Hello, world."); ``` See [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md). ## Multi-provider apps Pattern: one SDK instance per provider, mix at the call site: ```ts const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const openrouter = new OpenAISDK({ apiKey: process.env.OPENROUTER_API_KEY!, baseURL: "https://openrouter.ai/api/v1", provider: "openrouter", }); const fastAgent = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }) }); const claudeAgent = ai.agent({ model: openrouter.model({ name: "anthropic/claude-3.5-sonnet" }) }); ``` Reports label per-agent provider correctly. Pricing applies per SDK instance. ## When the adapter changes If you switch providers mid-project (e.g. OpenAI → Anthropic): 1. The agent factory call signature stays the same — `ai.agent({ model: .model({...}) })`. 2. Capabilities matter — if the new model doesn't support `structuredOutput` natively, fall back to the soft "respond in JSON only" instruction (framework handles it). 3. Errors stay typed — `ProviderAuthError`, `ContextLengthExceededError`, etc. are adapter-agnostic. 4. Pricing matrix needs updating per the new provider's rates. ## See also - [`@warlock.js/ai-openai/setup-openai/SKILL.md`](@warlock.js/ai-openai/setup-openai/SKILL.md) — full OpenAI adapter docs - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — model passed into `ai.agent({...})` - [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md) — embedder primitive on the SDK - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — adapter error categorization ## rag-loaders-and-stores `@warlock.js/ai/rag-loaders-and-stores/SKILL.md` --- name: rag-loaders-and-stores description: 'Turn any source into a RagDocument and index it in a production vector store — the document loaders ai.rag.loadText / loadHtml / loadWeb (SSRF-safe via guardedFetch) / loadPdf (lazy pdf-parse peer), plus the swappable stores ai.rag.pgVectorStore({client}) (pgvector + ensureSchema DDL + hnsw/ivfflat index) and ai.rag.cacheVectorStore(driver), both satisfying VectorStoreContract (upsert / query / removeNamespace). Loaders return the exact RagDocument[] that kb.index() consumes — no adapter. Triggers: `ai.rag.loadText`, `ai.rag.loadHtml`, `ai.rag.loadWeb`, `ai.rag.loadPdf`, `loadText`, `loadHtml`, `loadWeb`, `loadPdf`, `ai.rag.pgVectorStore`, `ai.rag.cacheVectorStore`, `pgVectorStore`, `cacheVectorStore`, `VectorStore`, `PgVectorStoreOptions`, `PgVectorStoreInstance`, `ensureSchema`, `schema()`, `RagLoaderResult`, `LoadWebOptions`, `LoadPdfOptions`, `perPage`, `OutboundPolicy`, `guardedFetch`, `hnsw`, `ivfflat`, `pgvector`, `dimensions`, `PgClientLike`, `PDF_PARSE_INSTALL_INSTRUCTIONS`; ''load a website into a knowledge base'', ''index a PDF for RAG'', ''strip HTML to text for embedding'', ''pgvector store for RAG'', ''SSRF-safe document fetch'', ''one document per PDF page'', ''swap the vector store''; typical import `import { ai } from "@warlock.js/ai"`. Skip: the chunk → embed → retrieve → rerank → cite pipeline that consumes these — `@warlock.js/ai/run-ai-rag/SKILL.md`; the raw embedder primitive — `@warlock.js/ai/embed-text/SKILL.md`; cache similarity internals — `@warlock.js/cache/use-cache-similarity/SKILL.md`; competing libs `langchain` loaders, `llamaindex` readers.' --- # RAG loaders + vector stores — source → `RagDocument` → durable store Two feature groups that bracket `ai.rag()`: **loaders** turn a source (string, raw HTML, a URL, or PDF bytes) into the exact `RagDocument[]` shape `kb.index()` consumes, and **stores** are the swappable backends that hold the embeddings. Both live on the `ai.rag.*` namespace — present the moment `@warlock.js/ai` is imported, no side-effect import, no module augmentation. ## Contract — what each side produces / satisfies Every loader returns `RagLoaderResult` — a plain `RagDocument[]` — so a load feeds `index()` with no adapter and callers never branch on arity (one page ⇒ 1 doc, a per-page PDF ⇒ N docs): ```ts type RagLoaderResult = RagDocument[]; type RagDocument = { id: string; text: string; metadata?: Record; tags?: string[] }; ``` Every store satisfies the three-method `VectorStore` contract (a thin narrowing of the cache `similar()` surface — NOT a new engine): ```ts interface VectorStore { upsert(key: string, value: unknown, vector: number[], tags?: string[]): Promise; query(vector: number[], options: { topK: number; threshold?: number; tags?: string[] }): Promise<{ key: string; value: T; score: number }[]>; removeNamespace(namespace: string): Promise; } ``` ## Loaders | Loader | Input | Deps | Emits | |---|---|---|---| | `ai.rag.loadText(input, opts?)` | `string` \| `{ id, text }` \| array of either | none | one doc per non-empty item | | `ai.rag.loadHtml(html, opts?)` | raw HTML string | none (regex strip) | one doc, `metadata.title` from `` | | `ai.rag.loadWeb(url, opts?)` | absolute URL | none (uses core `guardedFetch`) | one doc, SSRF-safe fetch | | `ai.rag.loadPdf(bytes, opts?)` | `Buffer` \| `ArrayBuffer` \| `Uint8Array` | lazy `pdf-parse` peer | one doc, or one per page with `perPage: true` | Shared options (`RagLoaderOptions`): `id` (source id — falls back to the URL for web, `"document"` otherwise), `metadata` (merged **over** the loader-derived keys, so an explicit `metadata.title` always wins), and `tags` (applied to every chunk for `retrieve({ tags })` filtering). Loader-derived keys: `source`, `loader` (`"text" | "html" | "web" | "pdf"`), plus `title` / `page` / `pageCount` / `contentType` where determinable. Empty / whitespace-only / all-markup inputs emit **no** document — never a no-op record for `index()` to skip. ```ts import { ai } from "@warlock.js/ai"; // Bare string, or many records → many distinctly-identified docs: await kb.index(ai.rag.loadText([ { id: "faq-billing", text: "…", metadata: { section: "billing" } }, { id: "faq-shipping", text: "…" }, ])); // Raw HTML → readable text (scripts/styles dropped, entities decoded): await kb.index(ai.rag.loadHtml(rawHtml, { id: "landing", tags: ["marketing"] })); ``` ### `loadWeb` is SSRF-safe — never a raw `fetch` Every request goes through core's `guardedFetch` under an `OutboundPolicy`. The strict defaults (https-only, private-IP-deny on, 10s timeout, 5 MiB cap) apply even when you pass no `policy`, so an untuned call is already hardened. Tighten it per call: ```ts await kb.index(await ai.rag.loadWeb("https://docs.example.com/guide", { policy: { hostAllowlist: ["docs.example.com"], maxBytes: 2_000_000, timeoutMs: 5_000 }, tags: ["docs"], })); ``` HTML responses run through the same tag-strip pass as `loadHtml`; non-HTML text (`text/plain`, markdown) is used verbatim. `metadata.source` is the resolved URL, `metadata.contentType` the server-reported type. A non-OK response, a policy block, a timeout, or an over-cap body throws `OutboundPolicyError`. **Redirects are re-validated per hop, not delegated to the platform (4.15.0).** A page a crawl reaches can `3xx` — `guardedFetch` re-runs each `Location` through the same scheme/host/private-IP checks before following it, capped at `policy.maxRedirects` (default `5`), and strips `authorization`/`cookie`/`proxy-authorization` on a cross-origin hop. So a redirect can never smuggle `loadWeb` into a private/metadata address the original URL couldn't have reached. Full guard detail (including `assertUrlAllowed`, `fetchTextWithPolicy`, and the other call sites sharing it): [`@warlock.js/ai/secure-outbound-requests/SKILL.md`](@warlock.js/ai/secure-outbound-requests/SKILL.md). ### `loadPdf` — lazy optional peer, page-precise citations `pdf-parse` is an **optional** peer, dynamic-imported on the FIRST `loadPdf` call — importing `@warlock.js/ai` never forces it. When it is absent, the curated `PDF_PARSE_INSTALL_INSTRUCTIONS` string is thrown as a plain `Error` (a missing infra peer, not a content problem), never a raw module-resolution stack trace. ```ts import { readFile } from "node:fs/promises"; // Whole PDF → one doc carrying metadata.pageCount: await kb.index(await ai.rag.loadPdf(await readFile("manual.pdf"), { id: "manual" })); // One doc per page → citations stay page-precise (id suffixed `#p<n>`, metadata.page set): await kb.index(await ai.rag.loadPdf(bytes, { id: "manual", perPage: true })); ``` An image-only / scanned page has no text layer and is dropped, so a fully-scanned PDF yields zero docs (nothing to embed). ## Stores ### `ai.rag.cacheVectorStore(driver)` — adapt any `@warlock.js/cache` driver The cache driver **is** the vector store — `upsert → set({ vector, tags })`, `query → similar()`, `removeNamespace → removeNamespace()`. A driver without similarity support throws `CacheUnsupportedError` unchanged (pointing you at the `pg` / `redis` cache drivers). ```ts import { MemoryCacheDriver } from "@warlock.js/cache"; const store = ai.rag.cacheVectorStore(new MemoryCacheDriver()); // dev / tests ``` ### `ai.rag.pgVectorStore(options)` — production pgvector One durable row per chunk keyed by the pipeline's dotted key, the chunk payload in a `JSONB` `value` column, the embedding in a pgvector `vector` column. Pass a live pool (`{ client }` — `@warlock.js/ai` imports **nothing**) or a `{ connectionString }` and let the store lazily `import("pg")` (the optional peer; curated install string on first use if absent). Exactly one of the two is required. ```ts type PgVectorStoreOptions = { client?: PgClientLike; // a pg.Pool / pg.Client — only `query` is ever called connectionString?: string; // else the store builds its own Pool lazily table?: string; // default "warlock_ai_rag_vectors"; must be a safe identifier dimensions?: number; // vector(N) width in the DDL, default 1536 index?: "hnsw" | "ivfflat" | "none"; // ANN strategy, default "hnsw" ivfflatLists?: number; // ivfflat only, default 100 }; ``` `schema()` (alias `ensureSchema()`) returns the reference migration DDL — `CREATE EXTENSION vector`, the table, a GIN index on `tags`, and the chosen ANN index (`USING hnsw (embedding vector_cosine_ops)`). It **only returns the string**; the framework never auto-migrates — you run it once through your own tool. Index and query MUST use the same embedding model: the `vector(N)` width is fixed at table-creation time from `dimensions`. ```ts import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const store = ai.rag.pgVectorStore({ client: pool, dimensions: 1536, index: "hnsw" }); // Once, via your migration tooling — never auto-run: await pool.query(store.ensureSchema()); ``` `query()` runs the cosine floor (`threshold`) and `tags` overlap filter **in SQL** (a below-floor row never crosses the wire), orders by `embedding <=> $vec`, caps at `topK`, and maps the pgvector distance back to a `[0,1]` cosine-similarity `score` — the same scale the cache store emits. `removeNamespace()` is a prefix DELETE that escapes `_` / `%` so dropping `ai.rag.docs` never also catches `ai.rag.docs2`. ## Pattern — a knowledge base from a website, backed by pgvector ```ts import { Pool } from "pg"; import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const store = ai.rag.pgVectorStore({ client: pool, dimensions: 1536 }); await pool.query(store.ensureSchema()); // once at boot / migration const kb = ai.rag({ name: "docs", embedder: openai.embedder({ name: "text-embedding-3-small" }), // 1536 dims — matches the DDL store, }); // Crawl a few pages (SSRF-safe) + a spec PDF into the same namespace: for (const url of ["https://docs.example.com/intro", "https://docs.example.com/config"]) { await kb.index(await ai.rag.loadWeb(url, { policy: { hostAllowlist: ["docs.example.com"] }, tags: ["docs"] })); } await kb.index(await ai.rag.loadPdf(await readFile("spec.pdf"), { id: "spec", perPage: true, tags: ["spec"] })); // Now retrieve — every hit's citation traces back to its source URL / page: const { chunks } = await kb.retrieve("how do I configure caching?", { topK: 4, tags: ["docs"] }); ``` The `embedder`'s `dimensions` MUST equal the store's `dimensions` — a mismatch is a runtime insert failure at the pgvector column, not a type error. ## Cost + testing - **Loaders are cheap.** `loadText` / `loadHtml` are zero-dependency string passes; `loadWeb` costs one guarded HTTP round-trip; `loadPdf` costs the `pdf-parse` parse. **None embed** — embedding cost lands entirely in `kb.index()` (batched, 96 texts per `embedMany` call). The token spend is per chunk, so `perPage` PDFs and finer chunking mean more, smaller vectors. - **`pgVectorStore` construction is synchronous and does no I/O** — the `pg` import + pool build are deferred to first `query`/`upsert`. Table-name validation (`/^[A-Za-z_][A-Za-z0-9_]*$/`) throws at construction, so a `table: "bad; DROP TABLE x"` fails fast. - **Unit-test loaders with fixtures** (a stubbed `policy.fetch` for `loadWeb`, `vi.mock("pdf-parse")` for `loadPdf` — the literal specifier is mockable). Test stores against a `FakePgClient` implementing `{ query }`, or `cacheVectorStore(new MemoryCacheDriver())` for a real end-to-end index/retrieve with no external service. ## See also - [[run-ai-rag]] — the chunk → embed → retrieve → rerank → cite pipeline that **consumes** these loaders and stores (`ai.rag({ embedder, store })`, `index()` / `retrieve()`). - [[embed-text]] — the `sdk.embedder` primitive whose `dimensions` must match the store's `vector(N)` width. - [[secure-outbound-requests]] — the full `guardedFetch` / `OutboundPolicy` guard `loadWeb` delegates to, including per-hop redirect revalidation and the other consumers sharing it. - [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — the cache driver `cacheVectorStore` adapts. ## record-replay-llm `@warlock.js/ai/record-replay-llm/SKILL.md` --- name: record-replay-llm description: 'Deterministic, offline LLM tests with ai.vcr(model,{path,mode}) — a record/replay decorator over ANY ModelContract that intercepts only complete()/stream(), delegates name/provider/capabilities/pricing to the inner model, and hashes each request against a JSON cassette on disk. Covers the three modes (record / replay / auto), the cassette format, save(), VcrCassetteMissError, streaming round-trip, hashOptions, and composing below fallbackModel. Triggers: `ai.vcr`, `vcr`, `VcrModel`, `VcrOptions`, `VcrMode`, `Cassette`, `CassetteEntry`, `VcrCassetteMissError`, `hashRequest`, `DEFAULT_HASH_OPTIONS`, `mode`, `path`, `hashOptions`, `save`, `cassette`, record, replay, cassette; ''record LLM responses for tests'', ''replay model calls offline in CI'', ''deterministic agent test without hitting the provider'', ''cassette for model calls''; typical import `import { ai } from "@warlock.js/ai"`. Skip: eval scoring + regression gating — `@warlock.js/ai/eval-datasets-and-ci/SKILL.md`; the Vitest matchers + mockRouter — `@warlock.js/ai/ai-dx-helpers/SKILL.md`; choosing a provider adapter — `@warlock.js/ai/pick-ai-provider/SKILL.md`; competing libs `nock`, `polly.js`.' --- # `ai.vcr()` — record / replay any model `ai.vcr(model, { path, mode })` wraps any `ModelContract` in a record/replay decorator backed by a JSON cassette on disk. It intercepts only `complete()` / `stream()` — the single seam every agent trip funnels through — and delegates `name`, `provider`, `capabilities`, and `pricing` to the inner model untouched, so cost accounting and capability detection are unchanged. Depends only on `ModelContract`, so it works with **any** adapter. ## Shape ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const live = openai.model({ name: "gpt-4o-mini" }); const model = ai.vcr(live, { path: "./cassettes/support.json", // cassette file (JSON); read on construct, written on save() mode: "auto", // "record" | "replay" | "auto" (default) }); const agent = ai.agent({ model, systemPrompt: "..." }); const result = await agent.execute("hi"); await model.save(); // first run records; later runs replay deterministically ``` The returned `VcrModel` is a `ModelContract` plus `save(): Promise<void>` and a readonly `cassette` (exposed for assertions). ## Modes — `VcrMode` - **`record`** — always calls the inner model and appends a cassette entry. Never replays (the in-memory cassette starts empty, so a record run never accidentally replays a stale entry). Use to (re)capture a fresh cassette. - **`replay`** — never calls the inner model. A cassette hit returns the stored response / re-yields its chunks / re-throws its error; a **miss throws `VcrCassetteMissError`** — never a silent live call. Use in CI for deterministic, offline tests. - **`auto`** (default) — replay on a hit, record on a miss. The friendliest mode for local dev: records once, replays thereafter. ## Request hashing — `hashOptions` On each call VCR computes a stable hash over `{ messages, picked options }` and looks for a matching `CassetteEntry`. The hashed option fields default to: ```ts ["temperature", "maxTokens", "responseSchema", "tools", "reasoning"] ``` (`DEFAULT_HASH_OPTIONS`). `signal` and unknown provider keys are **always excluded**, so an otherwise-identical logical call still matches. `tools` are hashed by name + description + input-schema shape, not object identity. Override `hashOptions` to widen / narrow what counts as "the same request". `hashRequest(messages, options, hashOptions)` is exported for direct use. ## Cassette format A `Cassette` is `{ version: 1, model, provider, entries: CassetteEntry[] }`. Each `CassetteEntry` is `{ requestHash, request: { messages, options? }, ... }` where **exactly one** of `response` / `chunks` / `error` is populated — mirroring the three outcomes of a model call (non-streaming reply, streamed chunk list, or a thrown provider error). The full `request` is stored verbatim for human readability and so the cassette can be re-hashed if the hashing format ever changes. ## Streaming round-trip ```ts for await (const chunk of model.stream(messages)) { // record mode: buffers each chunk into entry.chunks[] while re-emitting // replay mode: re-yields the stored chunks in order (delta / tool-call / done sequence) } await model.save(); ``` Recorded chunks reproduce the exact `delta` / `tool-call` / `done` sequence on replay; a recorded error is re-thrown. ## `VcrCassetteMissError` ```ts import { VcrCassetteMissError } from "@warlock.js/ai"; try { await vcrModel.complete(messages); } catch (error) { if (error instanceof VcrCassetteMissError) { console.error("Re-record the cassette:", error.path, error.requestHash); } } ``` Thrown only in `replay` mode on a miss (code `"VCR_CASSETTE_MISS"`). It carries the looked-up `requestHash` and the cassette `path` so a failing CI run names exactly which call was not recorded. Extends `AIError` directly (not `ProviderError`) — a miss is a harness/config failure, not a provider failure. **The whole point:** `replay` never falls back to a live call, which would silently re-introduce non-determinism into a test that asked for the opposite. Re-record by running once in `record` / `auto`. ## `save()` — flush new entries `save()` writes newly recorded entries to `path`. It is a **no-op when nothing was recorded** (pure replay, or a record/auto run that only hit cached entries), so calling it unconditionally is safe. ## Composition VCR composes **below** `ai.fallbackModel` and works with any adapter. Wrap the live model in `vcr(...)`, then pass it anywhere a `ModelContract` is accepted (agent, planner, reranker, judge). ## See also - [`@warlock.js/ai/eval-datasets-and-ci/SKILL.md`](@warlock.js/ai/eval-datasets-and-ci/SKILL.md) — pair a cassette with a dataset for fully offline eval CI - [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md) — `ai.mockRouter` + Vitest matchers for the rest of the test surface - [`@warlock.js/ai/pick-ai-provider/SKILL.md`](@warlock.js/ai/pick-ai-provider/SKILL.md) — the adapters whose models VCR wraps ## refine-prompts `@warlock.js/ai/refine-prompts/SKILL.md` --- name: refine-prompts description: 'Prompt compiler — systemPrompt(...).refined({ model, criteria, store }): humans keep writing human prompt text (dev code or admin-panel textareas); the refined wrapper lazily rewrites it into a model-optimized version via a refiner model on first agent use, pins the result (lockfile posture — recompiled ONLY when the source text, refiner model, criteria, or built-in recipe version change, never silently over time), and serves the pin thereafter. Explicit surfaces: await refined.refine() → the compiled template STRING (placeholders intact — for admin routes, previews, boot warmup, CI; throws PromptRefinementError on failure) and await refined.refinePrompt() → a composable SystemPromptContract with meta.refinedFrom / meta.refinerModel provenance (register it as a next version to unlock ai.prompts.diff review). Placeholder parity is machine-enforced (the exact {{placeholder}} set must survive or the rewrite is rejected after one repair re-ask); the lazy agent path NEVER throws — on refiner failure it warns once and serves the original. store is a structural { get, set } (any @warlock.js/cache CacheDriver); omitted ⇒ the pin lives on the instance for the process lifetime. Triggers: `refined`, `refine`, `refinePrompt`, `materialize`, `RefinedSystemPromptContract`, `RefinedSystemPromptOptions`, `RefinedPromptStoreLike`, `PromptRefineOptions`, `PromptRefinementError`, `refinedFrom`, `refinerModel`, `fresh`, `prompt-refiner`, ''refine a prompt'', ''compile a prompt'', ''optimize a system prompt'', ''rewrite my prompt to be AI-friendly'', ''admin-written prompts'', ''prompt refinement store''; typical import `import { ai } from "@warlock.js/ai"`. Skip: registry operations (register / resolve / tag / diff / validate) — `@warlock.js/ai/manage-prompts/SKILL.md`; composing prompts from persona + instruction blocks — `@warlock.js/ai/write-system-prompt/SKILL.md`; grading a prompt against rules without rewriting it — validate({ criteria }) in `@warlock.js/ai/manage-prompts/SKILL.md`.' --- # `systemPrompt().refined()` — the prompt compiler Humans write prompts as human text; models perform better on structured, model-tuned phrasing. `.refined({ model, criteria, store })` turns any `SystemPrompt` into a **lazily-compiled artifact**: the first agent use rewrites the raw source template through the refiner `model`, pins the result, and every later use serves the pin. The human text stays the editing surface forever — the refined text is a derived artifact, like a lockfile. ```ts import { ai } from "@warlock.js/ai"; const support = ai .systemPrompt( [ai.persona("You are a friendly assistant."), ai.instruction("Help {{name}} with orders.")], { name: "support" }, ) .refined({ model: refinerModel, store: myCacheDriver }); // Lazy: compiles on the first run, serves the pin afterwards. const agent = ai.agent({ model, systemPrompt: support }); // Explicit: compile now — admin routes, previews, boot warmup, CI. const text = await support.refine(); // the compiled template STRING const prompt = await support.refinePrompt(); // a composable SystemPromptContract ``` ## The four trust rules 1. **Lockfile posture.** The pin key hashes the recipe version + refiner model + `criteria` + source template — any input change compiles fresh; an unchanged input NEVER recompiles (no TTL, no silent drift). `store` is a **store, not a cache**. 2. **Prose, never contract.** The exact `{{placeholder}}` set (name **and** `|default`) must survive the rewrite verbatim — checked mechanically; a parity break gets ONE repair re-ask, then the rewrite is rejected. The compiled text is still a **template**: placeholders resolve per call as usual. 3. **Advisory with fallback.** The lazy agent path never throws: a refiner failure warns once (`[warlock-ai] …`) and serves the ORIGINAL prompt — the human text is always a valid prompt. After **3** failed attempts the lazy path stops retrying for the instance lifetime (no per-run refiner latency from a broken key/provider); the explicit `refine()` / `refinePrompt()` stay live — they **throw** `PromptRefinementError` (`error.reason`: `"model"` / `"parity"` / `"empty"`) and a later success re-arms the pin for everyone. 4. **Reviewable.** `refine()` exposes the compiled text; `refinePrompt()` makes it a first-class prompt with provenance. ## `refine(options?)` — the explicit string surface ```ts const text = await support.refine(); // store-first; pins on first compile const another = await support.refine({ fresh: true }); // skip the pin, new take, re-pins ``` Expose it via a route for an admin **preview / approve** flow — the admin sees original vs refined, and the call itself warms the pin so the next agent run pays nothing. Also the boot-warmup / CI-compile surface. ## `refinePrompt(options?)` — the composable surface ```ts const compiled = await support.refinePrompt(); compiled.blocks; // one instruction block = the refined template compiled.meta()?.refinedFrom; // "support@1" (or "anonymous") compiled.meta()?.refinerModel; // "anthropic:claude-sonnet-4-5" compiled.meta()?.required; // carried from the source — contract preserved ``` It never auto-registers (no `name` — registry versions stay human-intentional). Register it deliberately to unlock the review flow: ```ts compiled.meta({ name: "support" }); // registers as support@<next> ai.prompts.diff("support", "1", "2"); // original vs refined, block by block ``` ## Options - **`model`** (required) — the refiner `ModelContract`. The call runs as a one-shot `"prompt-refiner"` agent, so usage/cost surface through the standard report/observer machinery. - **`criteria`** — a string or list of rules the rewrite MUST satisfy, on top of the built-in recipe. Same word and shape as `validate({ criteria })`: *validate grades against criteria; refined rewrites against them*. Folded into the pin key — new rules compile fresh. - **`store`** — structural `{ get, set }` (`RefinedPromptStoreLike`; any `@warlock.js/cache` `CacheDriver` satisfies it — the cache package stays an optional peer). Share a redis/pg-backed driver so ONE process pays each compilation and the fleet reads the pin. Omitted ⇒ the pin lives on the wrapper instance for the process lifetime. A pinned value that fails the parity check (corrupt / tampered store) is treated as a miss and recompiled. ## What compiles where — the lazy boundary The lazy compile hook rides the **agent path** (`ai.agent` execute/stream, and everything built on it — supervisors' member agents, planner steps, eval, `spawnSubAgent`, `serve`). Prompts resolved **synchronously at factory time** — `ai.planner({ systemPrompt })` / `ai.router({ systemPrompt })` prefixes, a supervisor's own `systemPrompt` / `goal`, and `ai.prompts.resolve()` — use the ORIGINAL text unless you pre-warm: ```ts await refined.refine(); // warm the pin at boot… const planner = ai.planner({ systemPrompt: refined, ... }); // …then factories see it? NO — ``` Factory-time resolution reads whatever is pinned **at that moment** — so warm BEFORE constructing the factory, or pass `await refined.refinePrompt()` instead (an already-compiled plain prompt). ## Chaining and identity - `refined.meta()` reads the SOURCE meta — agent reports stamp the source `name@version`, so observability groups by the prompt you authored. - `.persona()` / `.instruction()` / `.merge()` / `.meta({...})` derive a NEW source and re-wrap it with the same refinement options — editing a compiled prompt invalidates its pin naturally (new source ⇒ new key). - `refined.source` is always the original builder; `refined.resolve(placeholders)` serves the compiled text once pinned, the original before. - **Register the source or the `refinePrompt()` output — not the wrapper itself.** The wrapper's `blocks` flip from source to compiled text on materialization, so `ai.prompts.register(wrapper)` would fingerprint whatever is pinned at call time (and a re-register after the flip throws on the content mismatch). - `validate()` on the wrapper validates what it currently serves — pair `refined` with `validate({ criteria, judge })` to lint the compiled text, and with `agent.eval` (original vs refined on a dataset) to PROVE the rewrite helps before trusting it. ## See also - [`@warlock.js/ai/manage-prompts/SKILL.md`](@warlock.js/ai/manage-prompts/SKILL.md) — the registry (`name@version`, tags, `diff`, `validate({ criteria })`) the review flow rides on - [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) — the `systemPrompt()` builder `.refined()` extends - [`@warlock.js/ai/eval-datasets-and-ci/SKILL.md`](@warlock.js/ai/eval-datasets-and-ci/SKILL.md) — measure original vs refined behaviour on a dataset ## run-ai-agent `@warlock.js/ai/run-ai-agent/SKILL.md` --- name: run-ai-agent description: 'Build agents with ai.agent({...}) — the single-LLM-turn primitive. Covers execute / stream, attachments, structured output, placeholders, events, agent.eval scoring, the judge-safe preset for resilient LLM-as-judge / verdict classifiers (ai.agent.judge / judge: true — lenient JSON parse + repair + never-throw, for Nova-class models), and auto-adapting raw executables in tools:[]. Triggers: `ai.agent`, `ai.agent.judge`, `agent.execute`, `agent.stream`, `agent.eval`, `AgentResult`, `AgentReport`, `AgentToolEntry`, `JudgeConfig`, `JudgeAgentConfig`, `judge`, `repairAttempts`, `streamingToolGuard`, `attachments`, `repair`, `maxTrips`, `sessionId`, `spawnSubAgent`, `SpawnSubAgentSpec`; ''run an agent'', ''stream an agent response'', ''structured output schema'', ''pass image to agent'', ''evaluate an agent'', ''LLM-as-judge that survives malformed JSON'', ''grade with a Nova model without crashing'', ''put a supervisor in tools'', ''cancel an agent run'', ''spawn a one-shot sub-agent with a per-task budget''; typical import `import { ai } from "@warlock.js/ai"`. Skip: tool definition — `@warlock.js/ai/define-ai-tool/SKILL.md`; workflows — `@warlock.js/ai/run-ai-workflow/SKILL.md`; eval matchers / batch / fallback detail — `@warlock.js/ai/ai-dx-helpers/SKILL.md`; competing libs `langchain`, `ai` (Vercel), raw `openai`.' --- # `ai.agent()` — single-turn primitive The lowest rung of the 4-primitive ladder. One LLM call, optional tool loop, optional structured output. Stateless across calls. ## Factory shape ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); ai.agent({ name?: string, // optional — anonymous gets a fingerprint model: openai.model({ name: "gpt-4o-mini" }), systemPrompt?: string | SystemPromptContract, tools?: AgentToolEntry<any, any>[], // ToolContract OR a raw executable (auto-adapted) placeholders?: Record<string, unknown>, maxTrips?: number, // default 10 modelOptions?: ModelCallOptions, output?: StandardSchemaV1<T>, // default structured-output schema middleware?: AgentMiddleware[], streamingToolGuard?: StreamingToolGuardConfig, // opt-in tool-call recovery from text leaks on?: AgentEventHandlers, version?: string, // mirrored onto reports for trip archives }); ``` The factory returns an `AgentContract<TOutput>`. Every execution spawns a fresh internal `Execution` — the factory holds no per-call state. ## Anonymous agents `name` is optional. Anonymous agents receive a deterministic fingerprint: ``` anon_<provider>_<model>[_<tool1>+<tool2>+...] ``` Same config across process restarts → same synthetic name. Keeps workflow signature drift detection honest when you compose anonymous agents into a workflow. ## Execute surface ```ts agent.execute(input: string, options?: AgentExecuteOptions): Promise<AgentResult<T>>; agent.stream(input: string, options?: AgentExecuteOptions): StreamContract<AgentResult<T>>; ``` `AgentExecuteOptions` — every field optional: ```ts { history?: Message[]; attachments?: Attachment[]; // images today; PDFs later placeholders?: Record<string, unknown>; output?: StandardSchemaV1<T>; // typed structured output → result.data responseSchema?: Record<string, unknown>; // hand-crafted JSON Schema escape hatch systemPrompt?: SystemPromptContract; // per-call override repair?: { maxAttempts?: number }; // opt-in re-ask on validation failure signal?: AbortSignal; // cancellation sessionId?: string; // stitch many runs into one session streamingToolGuard?: StreamingToolGuardConfig; on?: AgentEventHandlers; } ``` ## `streamingToolGuard` — recover tool calls leaked as text Cheap and fast models occasionally emit a registered tool's structured input as **literal text in the content stream** instead of as a real `tool_call`. Without intervention, customers watch raw JSON build character-by-character. ```ts ai.agent({ model: someFastModel, tools: [suggestFollowupsTool, searchCatalogTool], streamingToolGuard: {}, // empty object = on with defaults }); ``` Recovery conditions: the buffered JSON must (a) parse cleanly, (b) carry a `name` or `tool` key resolving to a registered tool, AND (c) carry an `arguments` or `input` key whose value validates against that tool's input schema. Anything else flushes back as text — the guard never invents calls. **Off by default.** Set this explicitly on agents whose registered tools have been observed to leak. ## `sessionId` — stitch many runs into one user session ```ts const sessionId = "sess_user_42_2026-05-12"; await agent.execute("what's my order?", { sessionId }); await agent.execute("cancel it", { sessionId }); // 30 seconds later, same session ``` The framework stamps it onto every report node this run produces. Cost dashboards can group by `sessionId` without joining the report tree. ## Result shape — `AgentResult<T>` ```ts type AgentResult<T> = { type: "agent"; data?: T; // structured output when `output` schema was supplied text?: string; // raw final LLM text report: AgentReport; // trips, toolCalls, status, timing usage: Usage; // aggregated token usage + cost breakdown error?: AIError; }; type AgentReport = { runId: string; rootRunId: string; name: string; status: "completed" | "failed" | "cancelled"; startedAt: string; endedAt: string; duration: number; model: { name: string; provider: string }; trips: LLMTrip[]; children: ToolCall[]; // tool dispatches — filter by `c.type === "tool"` }; ``` Tool calls are NOT a separate `report.toolCalls` field — every tool dispatch is a child `BaseReport` node (`type: "tool"`) on `report.children`. Filter the tree to isolate them: ```ts const toolCalls = report.children.filter((c) => c.type === "tool"); const nestedAgents = report.children.filter((c) => c.type === "agent"); ``` Canonical destructuring: ```ts const { data, text, report, usage, error } = await agent.execute(input); if (error) { logger.warn(error.code, { duration: report.duration, trips: report.trips.length }); return; } ``` ## Pattern — structured output ```ts import { v, type Infer } from "@warlock.js/seal"; const summarySchema = v.object({ summary: v.string(), keyPoints: v.array(v.string()).min(1), }); const result = await myAgent.execute(input, { output: summarySchema }); if (result.data) { // typed as Infer<typeof summarySchema> } ``` Adapters with `capabilities.structuredOutput: true` forward the schema natively. Adapters without it get a soft "respond in JSON only" instruction. Client-side validation always runs. ## Pattern — output baked into the agent ```ts const titleAgent = ai.agent({ model: openai.model({ name: "gpt-4o-mini" }), output: titleSchema, // typed end-to-end via AgentContract<Infer<typeof titleSchema>> systemPrompt: "...", }); const result = await titleAgent.execute(currentMessage, { history }); // ^? AgentResult<{ title?: string }> ``` Call-site `options.output` fully **replaces** `config.output` for that run — no merging. ## Pattern — repair on validation failure ```ts await myAgent.execute(input, { output: schema, repair: { maxAttempts: 1 }, // re-ask once on parse/validation failure }); ``` Disabled by default. Each repair attempt counts against `maxTrips`. ## `judge` preset — resilient LLM-as-judge / verdict classifiers For graders and verdict classifiers running on models that emit **corrupted** structured output — notably the Amazon Nova family, which wraps verdicts in fenced ` ```json ` blocks, prepends prose, or trails commentary — set `judge: true` (or a `JudgeConfig`). It turns on three behaviors at once: ```ts const grader = ai.agent.judge({ model: nova.model({ name: "amazon.nova-pro-v1:0" }), systemPrompt: "Grade the answer. Respond with JSON only.", output: verdictSchema, }); const result = await grader.execute(prompt); if (result.error) { // graceful default — the judge couldn't produce a clean verdict } ``` 1. **Repair** — a couple of re-ask attempts by default (`repairAttempts`, defaults to `2`; bounded by `maxTrips`) when the verdict fails to parse / validate. The caller's per-call `options.repair` still wins. 2. **Lenient verdict parsing** — extracts the first balanced JSON object / array (tolerating fenced blocks + surrounding prose) instead of the strict parser. 3. **Never throws on a parse miss** — even an unparseable verdict yields a well-formed result (`result.error` populated, `result.data` undefined), so a flaky judge degrades instead of crashing the flow. `ai.agent.judge(config, judge?)` is sugar for `ai.agent({ ...config, judge })`; the bare `ai.agent({ judge: true })` option does the same. `judge: {}` ≡ `judge: true` (every field falls back to its resilient default); `judge: { repairAttempts: 0 }` keeps the lenient parser + never-throw guarantee but disables repair. **Trade-off — resilience over strictness.** The lenient parse can recover JSON the strict parser would (correctly) reject — leave `judge` **off** for normal structured output, where a hard parse failure is a useful signal. Off by default; omitting it parses strictly and never auto-enables repair, byte-for-byte as before. (This is the same Nova-safe judge the unified prompt `validate()` uses — see [`@warlock.js/ai/manage-prompts/SKILL.md`](@warlock.js/ai/manage-prompts/SKILL.md).) ## Pattern — image attachments ```ts await myAgent.execute("What's in this?", { attachments: ["./photo.png", "https://cdn.example.com/cat.jpg"], }); ``` Shorthand strings infer the image kind from extension. Tagged form for explicit control: ```ts attachments: [ { type: "image", source: "./photo" }, { type: "image", source: { base64: "...", mediaType: "image/png" } }, ]; ``` Model must declare `capabilities.vision`. OpenAI adapter auto-infers from name; override with `openai.model({ name, vision: true })`. A URL *image* attachment is passed to the provider as a URL — the provider fetches it, not the framework, so there's no server-side SSRF surface. A **remote `{ type: "text", source: <url> }` attachment IS fetched server-side** (the adapter needs the raw text inline) and is default-DENY: it throws unless `attachmentPolicy.allowRemoteFetch: true`, and when enabled runs through the shared `guardedFetch` / `OutboundPolicy` guard — see [`@warlock.js/ai/secure-outbound-requests/SKILL.md`](@warlock.js/ai/secure-outbound-requests/SKILL.md). ## Pattern — streaming ```ts const stream = myAgent.stream(input); for await (const event of stream) { if (event.type === "agent.trip.streaming") { process.stdout.write(event.delta); } } const result = await stream.result; ``` Or use `.on({ "agent.trip.streaming": ..., "agent.completed": ..., "agent.error": ... })` alongside iteration. ## Pattern — cancellation ```ts const ctrl = new AbortController(); const resultPromise = myAgent.execute(input, { signal: ctrl.signal }); setTimeout(() => ctrl.abort("too slow"), 30_000); const { error, report } = await resultPromise; if (report.status === "cancelled") { // error is an AgentCancelledError (code "AGENT_CANCELLED", // category "cancelled") carrying `cancelledAt` + `reason` } ``` Between-trip abort is guaranteed. Mid-trip best-effort. ## Events — dot-notation + 3-tier subscription - `agent.starting`, `agent.trip.started`, `agent.trip.streaming`, `agent.trip.completed` - `agent.tool.calling`, `agent.tool.called`, `agent.tool.failed` - `agent.completed`, `agent.error` Three subscription tiers — fire in order **factory → instance → per-call**: ```ts ai.agent({ model, on: { "agent.starting": () => metrics.inc("agent.runs") } }); const unsubscribe = myAgent.on("agent.error", ({ error }) => logger.error(error)); await myAgent.execute("go", { on: { "agent.trip.completed": ({ trip }) => console.log(trip.duration) }, }); ``` Every event payload carries `runId` and `rootRunId`. Same identity fields ride on stream events. ## `tools: []` — auto-adapt executables Each `tools` entry is either a built `ToolContract` (from `ai.tool(...)` or an explicit `.asTool(...)`) OR a **raw executable primitive** (`AgentContract` / `WorkflowInstance` / `SupervisorContract` / orchestrator) — auto-adapted into a `ToolContract` at factory time. The manifest is derived from the executable's `name` + `description` + (optional) `inputSchema`; dispatch flows through its `execute()`. ```ts const concierge = ai.agent({ model, tools: [billingWorkflow, supportSupervisor, lookupTool], // no .asTool() needed }); ``` `.asTool()` still works and takes precedence when you need a custom name / schema per use. A supervisor/orchestrator needs `inputSchema` on its config to drop straight into `tools: []`. See [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md). ## `agent.eval(options)` — score the agent against a suite ```ts const report = await myAgent.eval({ cases: [ { name: "capital", input: "Capital of Egypt?", expected: "Cairo" }, { name: "tone", input: "Comfort an upset user." }, // judge-scored ], scorers: [ai.eval.contains()], // default for cases w/o their own judge: { agent: judgeAgent, rubric: "Score 1.0 only if empathetic." }, // LLM-as-judge fallback passThreshold: 0.5, // default }); expect(report.passed).toBe(true); // true only when EVERY case passed ``` Each case runs through `execute(input)`; scorer precedence is per-case `scorers` → suite `scorers` → synthesized `judge` (throws at author time if a case resolves none). Built-in scorers on `ai.eval.*`: `exact()`, `contains()`, `predicate(fn)`, `judge(config)`. Full coverage — plus the Vitest matchers (`registerAiMatchers` / `toRouteTo` / `toConverge` / `toPassStep` / `toOutputShape`) — in [`@warlock.js/ai/ai-dx-helpers/SKILL.md`](@warlock.js/ai/ai-dx-helpers/SKILL.md). ## `ai.spawnSubAgent()` — one-shot delegation with a budget `ai.spawnSubAgent(spec)` is a thin wrapper over this same `ai.agent()`: it builds a fresh agent from the spec, optionally attaches a `budget` middleware, runs the `task` once, and returns the `AgentResult`. Not a sandbox or a separate runtime — a spawn is an ordinary new agent (empty conversation, its own tools/prompt). It is a **general** primitive: usable inside a tool, a workflow or planner step, a supervisor intent, or hand-rolled orchestration — it is NOT planner-specific (the planner engine never calls it). ```ts import { ai } from "@warlock.js/ai"; const result = await ai.spawnSubAgent({ name: "extract-entities", model, task: "Pull every company name from this article: ...", budget: { maxCostUSD: 0.05 }, // per-task spend cap — aborts when crossed output: companiesSchema, }); ``` The one field a bare agent config doesn't surface ergonomically is `budget` (`BudgetOptions` — `maxTokens` / `maxCostUSD`), equivalent to `ai.agent({ middleware: [ai.middleware.budget(...)] })` but promoted to a first-class spec field so a delegated subtask can't overrun its cap (distinct from `maxTrips`, which caps round-trips, not spend). The surface is **narrower** than `agent.execute()`: one-shot, with no `history`, `placeholders`, per-call events, or `repair`. The spawned `report` slots under the caller's `report.children[]`, so cost and traces roll up uniformly. Reach for it when you want a named single-use delegation with a hard spend cap; otherwise just build an `ai.agent()` and call it. ## When NOT to use this primitive - Multi-step pipeline with a fixed shape → [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) - Multi-agent routing with iteration → [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) ## See also - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — tool wiring + schema validation - [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) — persona / instruction builders - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `AIError` hierarchy - [`@warlock.js/ai/secure-outbound-requests/SKILL.md`](@warlock.js/ai/secure-outbound-requests/SKILL.md) — the `guardedFetch` / `OutboundPolicy` guard behind a remote text attachment fetch ## run-ai-rag `@warlock.js/ai/run-ai-rag/SKILL.md` --- name: run-ai-rag description: 'Retrieval-augmented generation with ai.rag({...}) — a chunk → embed → vector-store → retrieve → rerank → cite pipeline that reuses ai.embedder + a @warlock.js/cache CacheDriver. Covers index() / retrieve() / clear() / asTool(), chunking strategies (recursive | markdown | sentence | fixed), Citation / RetrievedChunk provenance, and the opt-in rerankers ai.rag.keywordReranker / ai.rag.llmReranker. Triggers: `ai.rag`, `rag.index`, `rag.retrieve`, `rag.clear`, `rag.asTool`, `RagConfig`, `RagDocument`, `RetrieveOptions`, `RetrieveResult`, `RetrievedChunk`, `Citation`, `ChunkOptions`, `ChunkType`, `ai.rag.keywordReranker`, `ai.rag.llmReranker`, `cacheVectorStore`, `VectorStore`, `topK`, `threshold`, `candidates`; ''build a knowledge base'', ''retrieve relevant chunks for a query'', ''cite the source of an answer'', ''chunk markdown for embedding'', ''rerank retrieval results'', ''expose retrieval as a tool''; typical import `import { ai } from "@warlock.js/ai"`. Skip: raw single-string embedding — `@warlock.js/ai/embed-text/SKILL.md`; exact + vector LLM-response cache — `@warlock.js/ai/attach-ai-middleware/SKILL.md` (ai.middleware.semanticCache); tool wiring — `@warlock.js/ai/define-ai-tool/SKILL.md`; competing libs `langchain`, `llamaindex`.' --- # `ai.rag()` — chunk → embed → retrieve → rerank → cite A self-contained retrieval pipeline. It reuses the embedder you already have (`provider.embedder(...)`), a `@warlock.js/cache` vector-capable `CacheDriver` as the store, and the composite-as-tool engine for `asTool()`. Zero new dependencies. `ai.rag` is a native core verb — present the moment `@warlock.js/ai` is imported (no module augmentation, no side-effect import). ## Factory shape ```ts import { ai } from "@warlock.js/ai"; import { MemoryCacheDriver } from "@warlock.js/cache"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const kb = ai.rag({ name: "docs", // default "rag" embedder: openai.embedder({ name: "text-embedding-3-small" }), // REQUIRED store: new MemoryCacheDriver(), // or ai.config({ defaultStore }) namespace: "ai.rag.docs", // default `ai.rag.<name>` chunk: { type: "markdown", size: 800, overlap: 120 }, // index() defaults reranker: ai.rag.keywordReranker(), // OFF by default (cosine-only) retrieve: { topK: 4, threshold: 0.5 }, // default retrieval knobs }); ``` Resolution is **loud at construction** (mirrors `ai.memory`): - `embedder` is **required** — a provider with no embedder must be caught here, not at first `index()`. - `store` falls back to `ai.config({ defaultStore })`; if neither resolves, the factory throws. ## Surface — `Rag` ```ts interface Rag { readonly name: string; index(docs: RagDocument[], chunk?: ChunkOptions): Promise<{ chunks: number }>; retrieve(query: string, options?: RetrieveOptions): Promise<RetrieveResult>; clear(): Promise<void>; asTool(options?: RagAsToolOptions): ToolContract<{ query: string }, RetrieveResult>; } ``` ## `index()` — chunk, embed (batched), store ```ts await kb.index([ { id: "guide", text: longMarkdown, metadata: { url: "/guide" }, tags: ["frontend"] }, { id: "faq", text: faqText }, ]); ``` A `RagDocument` is `{ id, text, metadata?, tags? }` — **you** load + parse documents to text (loaders are out of scope for v1). Each doc is split into chunks, embedded in sub-batches of 96 texts per `embedMany()` call (so one giant doc never blows the provider's per-request cap), and upserted. Returns the chunk count written. Empty / whitespace-only documents yield zero chunks — nothing is embedded. The per-call `chunk` arg overrides `config.chunk` for that index. ## Chunking — `ChunkOptions` All sizing is in **characters** (tokenizer-free; the embedder owns token counting). `chunk(text, options)` is also exported standalone. ```ts type ChunkType = "recursive" | "sentence" | "fixed" | "markdown"; { type?: ChunkType, // default "recursive" size?: number, // target chars per chunk, default 1000 overlap?: number, // chars carried between adjacent chunks, default 200 separators?: string[], // recursive only; default ["\n\n", "\n", ". ", " ", ""] } ``` - **`recursive`** (default) — separator-aware greedy packing, largest unit first. - **`markdown`** — heading/section-aware, then recursive within each section. - **`sentence`** — packs whole sentences up to `size`. - **`fixed`** — back-to-back character windows. Every chunk records its exact `[start, end)` span in the original text, so a `Citation.span` is precise. ## `retrieve()` — embed query, fetch, rerank, slice, cite ```ts const { query, chunks } = await kb.retrieve("how do I configure caching?", { topK: 4, // returned AFTER reranking. default 5 threshold: 0.5, // cosine floor at the store stage. default 0.5 candidates: 16, // pool fetched before rerank. default topK * 4 (clamped >= topK) tags: ["frontend"], // restrict to chunks whose source had one of these tags }); for (const hit of chunks) { console.log(hit.score, hit.text); console.log(hit.citation.sourceId, hit.citation.chunkIndex, hit.citation.span); } ``` `retrieve()` is **return-only** — it never auto-injects into a prompt. The caller formats the cited chunks (or uses `asTool()` for the agent loop). A `RetrievedChunk` carries `{ text, score, citation }`; the `Citation` is `{ sourceId, chunkIndex, span, score, metadata? }`. The reranker is **OFF by default** (cosine ranking only) unless `config.reranker` is set. ## Rerankers — opt-in, on `ai.rag.*` Both are exposed as namespaced helpers on the factory (`ai.rag.keywordReranker`, `ai.rag.llmReranker`). ```ts // Zero-dependency lexical reranker (BM25-lite keyword overlap). ai.rag.keywordReranker({ weight: 0.5 }); // weight in [0,1]; 1 = pure keyword, 0 = keep cosine // Model-backed reranker — one or more model calls per retrieval. ai.rag.llmReranker({ model: openai.model({ name: "gpt-4o-mini" }), batchSize: 10 }); ``` - **`keywordReranker`** — blends lexical query-term overlap with the original cosine score by `weight`; ties keep the incoming cosine order. Costs nothing beyond string splits. Reach for it when embedding-only ranking buries a keyword-rich chunk. - **`llmReranker`** — asks an LLM to grade each over-fetched candidate `0..1` and sorts by that. Candidates the model fails to score keep their cosine score, so a garbled reply degrades gracefully. Opt in only when precision beats latency/cost. Both implement the `RagReranker` contract, so you can write your own. ## `asTool()` — drop retrieval into an agent's `tools: []` ```ts const agent = ai.agent({ model: openai.model({ name: "gpt-4o" }), tools: [kb.asTool({ name: "search_docs", retrieve: { topK: 6 } })], }); ``` Input is `{ query: string }`; output is the `RetrieveResult`. Default tool name is `retrieve_<rag.name>`; `description` and a per-tool `retrieve` override are optional. Built via the same composite-as-tool engine every other primitive uses. ## `clear()` ```ts await kb.clear(); // drops every entry written under this rag's namespace ``` ## Advanced - `cacheVectorStore(driver)` + the `VectorStore` contract are exported for swapping in a custom store. - A stored chunk's namespaced key is `${namespace}.${sourceId}.${chunkIndex}`. ## See also - [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md) — the `sdk.embedder` primitive this consumes - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — what `asTool()` produces - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — wiring the retrieval tool into an agent ## run-ai-team `@warlock.js/ai/run-ai-team/SKILL.md` --- name: run-ai-team description: 'Manager-led multi-agent teams with ai.team({...}) — transparent sugar over ai.supervisor that maps a manager → route/router, members → intents, and a gate → evaluate, returning a REAL SupervisorContract (no new loop, no new contract). Covers the built-in gate strings "quality" (review-then-fix) and "verify" (test-then-fix), a custom gate function, role mapping (roles / gateKey), and the verbatim supervisor pass-throughs (goal / output / state / maxIterations / snapshotStore / on / observe). Triggers: `ai.team`, `TeamConfig`, `TeamGate`, `TeamGateFn`, `TeamMemberValue`, `manager`, `members`, `gate`, `roles`, `gateKey`, `buildQualityGate`, `buildVerifyGate`, `SupervisorContract`, `ReportType`; ''build a team of agents'', ''manager that delegates to members'', ''review then fix loop'', ''test then fix loop'', ''quality gate for a multi-agent run'', ''report type team''; typical import `import { ai } from "@warlock.js/ai"`. Skip: routing one input to a fixed roster directly — `@warlock.js/ai/run-supervisor/SKILL.md` (team is sugar over it); durable cross-turn sessions — `@warlock.js/ai/run-orchestrator/SKILL.md`; LLM-generated plans — `@warlock.js/ai/run-planner/SKILL.md`; competing libs `crewai`, `autogen`.' --- # `ai.team()` — manager + members + a quality gate `ai.team(config)` is **thin, transparent sugar over `ai.supervisor`**. It builds a `SupervisorConfig` from the team-shaped config, calls `supervisor(...)`, and returns the **unchanged** `SupervisorContract<TOutput>` — the exact object `ai.supervisor` returns. So `ctx.intents.<member>.execute()`, `.asTool()`, `.resume()`, snapshots, and events all stay intact. `team()` owns no loop of its own. The mapping: | team field | becomes supervisor field | | --- | --- | | `manager` | `route` (deterministic `{ route }`) XOR `router` (an agent / `RouterEntry`) | | `members` | `intents` | | `gate` | `evaluate` | Everything else passes through 1:1 — the sole exception is the report/result `type`, which is stamped `"team"` (see [Pass-throughs](#pass-throughs-verbatim-supervisor-semantics)). ## Shape ```ts import { ai } from "@warlock.js/ai"; import { v } from "@warlock.js/seal"; const codeTeam = ai.team({ name: "code-team", goal: "Ship a tested module that passes review.", manager: techLeadRouter, // an agent / RouterEntry → router; or { route } → deterministic members: { builder, reviewer, fixer }, // role-name → agent | workflow gate: "quality", // "quality" | "verify" | (ctx) => EvaluateResult output: v.object({ code: v.string() }), maxIterations: 6, // default 10 (supervisor's) }); const { data, report } = await codeTeam.execute("Build a debounce<T> utility."); ``` A `member` is an `AgentContract` or a `WorkflowInstance` (the `TeamMemberValue` union — the autocomplete-friendly common case; callback / full-entry intent shapes still work when forwarded). The keys are both the role names the manager routes to AND the keys `ctx.intents.<role>` exposes (the supervisor escape hatch is preserved). ## The manager — `route` XOR `router` ```ts // LLM-driven manager: an agent (or RouterEntry) → becomes SupervisorConfig.router manager: techLeadRouter // Deterministic manager: { route } → becomes SupervisorConfig.route manager: { route: (ctx) => (ctx.iteration === 0 ? "builder" : "reviewer") } ``` Exactly one form is forwarded — mutually exclusive, mirroring the supervisor's own `router` XOR `route` rule. A malformed manager surfaces the existing `SupervisorFailedError` downstream. ## Gates — `"quality"` | `"verify"` | a function A `gate` string selects a pre-built `evaluate` strategy; both desugar to a concrete `evaluate` callback that leans entirely on the already-shipped `EvaluateResult` semantics (`satisfied` terminates, `reassignTo` re-dispatches the fixer, `feedback` threads forward) — **no new termination or loop code**. ### `gate: "quality"` — review-then-fix After each iteration's members settle and merge into supervisor `state`, the gate reads `state.approved` (the `gateKey`, default `"approved"`). If truthy → `{ satisfied: true }`; otherwise → `{ reassignTo: "fixer", feedback: String(state.notes ?? "") }`. The reviewer's feedback (`state.notes`) threads into the next iteration. ### `gate: "verify"` — test-then-fix Identical shape but keyed on the tester's pass/fail slice `state.passed` (default `gateKey`) rather than a subjective score. On failure it re-dispatches the fixer; there is no feedback channel for a pass/fail signal, so none is threaded. > The named member whose `output` schema writes the gate slice must produce a boolean into `gateKey`. ### A custom gate (full escape hatch) ```ts gate: (ctx) => { if (ctx.state.score >= 0.9) return { satisfied: true }; return { reassignTo: "fixer", feedback: ctx.state.review }; } ``` Supplying a `TeamGateFn` instead of a string opts out of the sugar entirely while keeping the rest of `team()`'s wiring — it forwards straight to `SupervisorConfig.evaluate` with zero wrapping. ## Role mapping — `roles` + `gateKey` The string gates default to canonical role names. Override when your `members` keys differ: ```ts ai.team({ name: "qa-team", manager, members: { author, critic, patcher }, gate: "quality", roles: { reviewer: "critic", fixer: "patcher" }, // map gate roles → your member keys gateKey: "ok", // state slice the gate reads }); ``` **Construction-time validation:** when the gate is a string, the resolved `fixer` (and, for `"quality"`, the `reviewer`) role is checked against `members`. A missing role throws an authoring-style `SupervisorFailedError` (`context: { authoring: true }`) immediately — rather than silently starving until `maxIterations`. ## Pass-throughs (verbatim supervisor semantics) `goal`, `output`, `state`, `maxIterations`, `snapshotStore`, `on`, `observe`, and `version` are forwarded unchanged. Because the returned object IS a supervisor, observability rides the same generic `Observer` seam every other flow uses (see `observe-ai-flows`), and snapshot resume works exactly as on a bare supervisor. The one behavioural difference from a bare supervisor: a team stamps **`type: "team"`** on both its report (a first-class `ReportType`, was `"supervisor"`) and its result, so Panoptic and any `Observer` can distinguish, group, filter, and label team runs as their own type rather than folding them into plain supervisor runs. Everything else passes through 1:1. A member callback that calls `agent.execute()` **directly** still nests `member → agent → tool` under the member span with usage rolled up — the same ambient-`RunFrame` auto-nesting as a bare supervisor. See [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md). ## See also - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — the primitive team desugars into (intents, route/router, evaluate, ctx.intents) - [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md) — wrap a team in durable cross-turn session state - [`@warlock.js/ai/observe-ai-flows/SKILL.md`](@warlock.js/ai/observe-ai-flows/SKILL.md) — the `observe` seam a team inherits ## run-ai-workflow `@warlock.js/ai/run-ai-workflow/SKILL.md` --- name: run-ai-workflow description: 'Build durable resumable pipelines with ai.workflow({...}) + ai.step({...}) — lifecycle (skip / before / run|agent|parallel / output / after / nextStep), retry, parallel groups, snapshot resume. Triggers: `ai.workflow`, `ai.step`, `wf.execute`, `wf.resume`, `WorkflowContext`, `WorkflowResult`, `StepSnapshot`, `nextStep`, `onFailure`, `WorkflowDriftError`; ''build a workflow'', ''define a step'', ''resume after crash'', ''parallel steps'', ''retry with backoff''; typical import `import { ai } from "@warlock.js/ai"`. Skip: agent — `@warlock.js/ai/run-ai-agent/SKILL.md`; supervisor — `@warlock.js/ai/run-supervisor/SKILL.md`; competing libs `temporal`, `inngest`, `bullmq`.' --- # `ai.workflow()` — static, deterministic pipelines Second rung of the 4-primitive ladder. A named, ordered set of steps with a stable signature. Each step is exactly one of: an agent call (`agent`), a `run` function, or a parallel group (`parallel`). Compose another workflow in by wrapping it with `workflow.asTool()` and calling it from a `run` step. Durable (resumable via any `CacheDriver` from `@warlock.js/cache`), observable, cancellable. ## When NOT to use a workflow - Unknown shape at author time → wait for `ai.planner()` (v3) - Quality-loop until goal met → [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) - Multi-turn conversation with persistent session → orchestrator (v2) - Iterate a runtime list of items → `ai.batch()` utility wrapping a workflow ## Minimal shape ```ts import { ai } from "@warlock.js/ai"; import { MemoryCacheDriver } from "@warlock.js/cache"; import { v } from "@warlock.js/seal"; ai.config({ defaultStore: new MemoryCacheDriver() }); type CatalogInput = { url: string }; type CatalogOutput = { id: string }; type CatalogState = { html?: string; catalogId?: string }; const wf = ai.workflow<CatalogInput, CatalogOutput, CatalogState>({ name: "catalog-item", output: { extract: (ctx) => ({ id: ctx.state.catalogId ?? "" }), schema: v.object({ id: v.string() }), }, steps: [ ai.step<CatalogInput, CatalogState>({ name: "fetch", run: async (ctx) => { ctx.state.html = await fetch(ctx.input.url).then(r => r.text()); }, }), ai.step<CatalogInput, CatalogState>({ name: "extract", agent: extractorAgent, input: (ctx) => ({ prompt: `Extract from: ${ctx.state.html}` }), output: { extract: (ctx) => ctx.agentResult?.data, schema: itemSchema, }, retry: { attempts: 3, backoff: "exponential" }, }), ], }); ``` ## Generics ```ts ai.workflow<TInput, TOutput, TState, TContext>(...) ai.step<TInput, TState, TContext>(...) ``` Order: Input/Output describe the public contract, State before Context because step bodies touch state more often. Defaults (`unknown`, `Record<string, unknown>`) let partial typing work. ## Execute — two interchangeable shapes ```ts // canonical — mirrors agent.execute const result = await wf.execute( { url: "https://..." }, { runId: "catalog-123", signal: AbortSignal.timeout(60_000) }, ); // single-object — ergonomic alt const result = await wf.execute({ input: { url: "https://..." }, runId: "catalog-123", }); ``` `WorkflowRunOptions` carries `runId`, `signal`, `on`, `context`, `sessionId`. `WorkflowDefinition.version` mirrors onto every produced report. ## `execute()` never throws All failures funnel into `result.error`: - `StepFailedError` / `STEP_FAILED` - `RoutingError` / `WORKFLOW_INVALID_GOTO` - `WorkflowDriftError` / `WORKFLOW_DRIFT` - `WorkflowCancelledError` / `WORKFLOW_CANCELLED` - `MaxStepsExceededError` / `WORKFLOW_MAX_STEPS` See [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md). ## Result shape ```ts const { data, report, usage, error } = await wf.execute(input); ``` ```ts type WorkflowResult<TOutput> = { type: "workflow"; data?: TOutput; // from workflow.output.extract report: WorkflowReport; // runId, signature, status, timings, per-step snapshots usage: Usage; // aggregated across all agent calls error?: AIError; }; ``` `report.steps[name]` holds a frozen `StepSnapshot` with `output`, `status`, `attempts`, `attemptHistory`, timings, nested children for parallel groups. ### Run-step sub-agent nesting `report.children` collects every step's captured executable report — a `step.agent`'s report, AND (mirroring the supervisor/team/orchestrator callback pattern — [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md)) anything a `run` step's callback invoked DIRECTLY, e.g. `agent.execute(...)` rather than the declarative `agent` field: ```ts ai.step({ name: "summarize", run: async (ctx) => { const result = await summarizerAgent.execute(ctx.input.text); // direct call — still nested return { prose: result.text }; }, }), // report.children → [ agentReport("summarizer") ] — usage/cost rolled up into // report.usage, and the agent does NOT also appear as a separate top-level // observed trace (an ambient RunFrame suppresses that self-routing). ``` No manual id threading, no separate observer wiring — an `Observer` (or Panoptic's dashboard) sees ONE tree: `workflow → agent → tool`, not a disconnected `workflow` trace next to a disconnected `agent` trace. `sessionId` propagates onto the captured subtree the same way. A `run` step's captures land as DIRECT children of the workflow report (the same tree position a `step.agent` report already occupies) — there's no intermediate "step" node, unlike supervisor's "callback" node. Only a step's LAST retry attempt's captures survive into the final snapshot; a failed attempt that called an agent and then threw never leaks that capture into a subsequent successful retry. A standalone `agent.execute()` call OUTSIDE any workflow step is unaffected — it keeps its own self-root, same as always. ## Step lifecycle ``` skip? → before? → (run | agent | parallel) → output.extract (+ schema) → after? → nextStep? ``` Exactly one of `run` / `agent` / `parallel` per step (enforced at `ai.step()` author time). | Phase | Purpose | | --- | --- | | `skip` | Return `true` to skip the step. Output becomes `undefined`. `nextStep` still fires. | | `before` | Pre-work — fetch, set state, validate. | | `run` | Core non-agent work. | | `agent` | Agent to execute. Takes `input(ctx)` as prompt builder. | | `input` | Required when `agent` is set. | | `output` | `{ extract, schema? }` — extracts the step's output. | | `after` | Post-work — save, notify. | | `nextStep` | Step-level routing on `completed` / `skipped`. | | `onFailure` | Step-level recovery routing after retries exhaust. | | `onCancel` | Cleanup if cancelled in-flight. | Errors in `before`/`run`/`agent`/`after`/`output` are retryable. Errors in `nextStep` and `onFailure` terminate the workflow with `RoutingError`. ## Context (`ctx`) ```ts type WorkflowContext<TInput, TState, TContext> = { readonly input: TInput; // frozen — durable cause readonly context: TContext; // frozen — per-execution readonly steps: Record<string, StepSnapshot>; // frozen snapshots of COMPLETED steps state: TState; // mutable current shared state readonly agentResult?: AgentResult<unknown>; // set when current step has an agent readonly runId: string; readonly signal?: AbortSignal; readonly startedAt: Date; }; ``` `input`, `context`, `steps` are deep-frozen. `state` is mutable during a step and frozen into `steps[name].state` on completion. ### `input` vs `context` - `input` answers *what* to process — persisted in the snapshot, replayed verbatim on `resume()`. - `context` answers *who's running it* — tenancy, user, locale, traceId. **Never persisted.** Callers pass fresh on every `execute()` and `resume()`. **Resume rule.** No fingerprinting on context. Persistence-scoping fields (e.g. `organizationId`) MUST match across resume — silent data corruption otherwise. ## State vs `steps[x].output` — performance - **Small control data** (flags, counters) → `ctx.state`. Cheap. - **Large artifacts** (HTML blobs, embedding vectors) → producer's `output.extract`, read via `ctx.steps[prev].output`. `ctx.state` clones on every retry attempt; `ctx.steps` clones once on step commit. ## Parallel children ```ts ai.step({ name: "generate", parallel: [ ai.step({ name: "draft", agent: writerAgent, input, output }), ai.step({ name: "suggest-articles", agent: kbAgent, input, output }), ], }); ``` - Children share `ctx.state` — last-write-wins. - Addressable by flat (`ctx.steps.draft`) AND nested (`ctx.steps.generate.steps.draft`) path. - Any child fails → all siblings still complete (atomic settlement); parent's `error` becomes the first child's error. - Checkpoint atomically after all children settle. ## Routing — `nextStep` (success) + `onFailure` (failure) ```ts ai.step({ name: "qa", agent: qaReviewerAgent, input, output, nextStep: (ctx) => { if (!ctx.agentResult?.data.approved) { ctx.state.qaFeedback = ctx.agentResult?.data.feedback; return { goto: "draft" }; // success-path route } }, onFailure: (ctx, error) => { if (error.code === "PROVIDER_RATE_LIMIT") { return { goto: "fallbackQa" }; } // void → halt with the original StepFailedError }, }); ``` Returns: `{ goto: "stepName" }`, `{ end: true }`, or `void` (fall through / halt). **Guards:** `maxSteps` (default 100) hard-fails with `MaxStepsExceededError`. `loopWarnAfter` (default 5) emits `workflow.loop.warning`. ## Retry ```ts retry: { attempts: 3, // default 1 = no retry backoff: "exponential", // "none" | "linear" | "exponential" | (attempt) => ms retryOn: (error, attempt) => true, onRetry: (attempt, error) => {}, } ``` Exponential defaults: 500 ms → 1 s → 2 s → 4 s → 8 s, capped at 30 s. `AbortError` short-circuits retry. ## Cancellation ```ts const ctrl = new AbortController(); const result = wf.execute({ input, signal: ctrl.signal }); ctrl.abort("user cancelled"); ``` Between-step cancellation is guaranteed. Mid-step is best-effort. `status: "cancelled"` on return with partial `report.steps`; checkpoint written before returning (resume works). ## Persistence & resume See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md). ```ts await wf.execute({ input, runId: "ticket-123" }); // fresh run await wf.resume("ticket-123"); // after crash ``` ## Events — three-tier subscription `workflow.starting`, `workflow.step.{starting|streaming|completed|skipped|retrying|failed}`, `workflow.loop.warning`, `workflow.cancelled`, `workflow.completed`, `workflow.error`. Subscription order: **definition → instance → per-call** (all matching handlers fire). Every payload carries `runId` and `rootRunId`. ## Design reference `domains/ai/design/workflow.md` — locked spec, §1–§16 covers every rule with five PoC examples. ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — agents inside steps - [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — snapshot resume + drift - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `WorkflowError` subclasses ## run-orchestrator `@warlock.js/ai/run-orchestrator/SKILL.md` --- name: run-orchestrator description: 'Durable stateful sessions with ai.orchestrator({...}) — the capstone of the 4-primitive ladder. Wraps a supervisor with cross-turn session state (checkpointStore), per-turn windowing, drift detection, post-turn compaction, mid-turn resume (iterate: true + snapshotStore), per-turn memory, typed commands, asTool, and a 3-tier event model. Triggers: `ai.orchestrator`, `orchestrator.execute`, `orchestrator.resume`, `orchestrator.command`, `orchestrator.stream`, `OrchestratorConfig`, `OrchestratorResult`, `OrchestratorReport`, `OrchestratorContract`, `CheckpointStore`, `OrchestratorDriftError`, `sessionId`, `iterate`, `historyWindow`, `summarize`, `keepSnapshots`, `awaiting-input`, `turns[]`, `TurnSnapshot`, `CompactionResult`, `initialAgent`, `checkpointStore`; ''multi-turn conversation that persists'', ''durable session across calls'', ''resume an interrupted turn'', ''compact session history'', ''per-session memory''; typical import `import { ai } from "@warlock.js/ai"`. Skip: a single routing turn with no session — `@warlock.js/ai/run-supervisor/SKILL.md`; a fixed pipeline — `@warlock.js/ai/run-ai-workflow/SKILL.md`; the store factories themselves — `@warlock.js/ai/manage-ai-stores/SKILL.md`; competing libs `langgraph`, `crewai`.' --- # `ai.orchestrator()` — durable stateful sessions The capstone of the 4-primitive ladder. An orchestrator is a **session-state manager wrapped around a supervisor**: each `execute` / `stream` call is ONE turn against a named `sessionId`, with the session's accumulated state, drift signature, and compaction progress persisted in a `CheckpointStore` between calls. The "what runs" fields (`intents`, `route` / `router`, `evaluate`, `state`, `output`, `initialAgent`, `maxIterations`) are the supervisor's surface spread directly — the orchestrator builds the supervisor lazily per turn and delegates to it. You never see the supervisor object. ## When to reach for it - **`supervisor`** — routes one input to a specialist each turn; stateless between runs unless you wire `snapshotStore`. No cross-turn session memory. - **`orchestrator`** — when the **session** matters: a long-running conversation where each turn must rehydrate the prior turn's state, history must be windowed/compacted, and an interrupted turn must resume after a crash. ## Shape ```ts import { ai } from "@warlock.js/ai"; import { END } from "@warlock.js/ai"; type SessionState = { category?: string; order?: { id: string }; reply?: string }; const supportBot = ai.orchestrator<SessionState>({ name: "refund-support", intents: { classify, lookup, process, compose }, route: (ctx) => (ctx.iteration === 0 ? "classify" : END), iterate: true, // delegate each turn to a real supervisor historyWindow: { router: 5, agents: 20 }, summarize: { afterTurns: 20, keep: 6 }, // auto-compaction policy keepSnapshots: 100, // turns retained per session checkpointStore: ai.checkpoint.pg({ client: pg }), snapshotStore: ai.snapshot.pg({ client: pg }), // required when iterate: true }); const result = await supportBot.execute(message, { sessionId: "sess_42", history }); if (result.report.status === "awaiting-input") { // session continues — wait for the next user turn } ``` `route` XOR `router` is required (mutually exclusive). `initialAgent`, when set, must be a key in `intents` and dispatches on turn 0, skipping the first route/router call. All config-shape errors throw `OrchestratorConfigError` at construction (author-time), not on the first turn. ## The session is owned by `sessionId` — passed per call There is no stateful session object and no implicit "current session" — every method names the session it acts on via `options.sessionId`. `history` is **required** on every `execute` call: the framework never persists raw messages (it owns session *state*, not the message log — that is the dev's store). `state` is a partial seed/patch shallow-merged into the loaded session state; `context` is the request-scoped bag, frozen at intake. ```ts await supportBot.execute(input, { sessionId: "sess_42", // required — names the session history: priorMessages, // required — the dev re-supplies prior turns each call state: { tier: "gold" }, // partial patch shallow-merged into loaded state context: { userId, db }, // request-scoped, never persisted signal: AbortSignal.timeout(60_000), on: { "orchestrator.turn.awaiting-input": (e) => log(e) }, // tier-3 per-call handlers force: false, // bypass drift check for this call }); ``` ## The turn lifecycle (what each turn does) 1. **load** — read the latest checkpoint for `(name, sessionId)`; seed empty on first call (`orchestrator.session.loaded`). 2. **drift check** — compare the loaded checkpoint's `signature` to the current definition (`orchestrator.drift.checked`). Mismatch throws `OrchestratorDriftError` unless `force: true`. 3. **lock wait** — wait on the compaction lock if held (`orchestrator.lock.waiting`). 4. **window** — slice history per `historyWindow.{router,agents}` (`orchestrator.history.windowed`). 5. **dispatch** — `route`/`router` (or `initialAgent` on turn 0) picks the intent(s); the supervisor runs the turn (`orchestrator.turn.routed`, `orchestrator.turn.streaming`). 6. **persist** — append a checkpoint row for the settled turn, then prune to `keepSnapshots` (`orchestrator.checkpoint.persisted`). 7. **compaction** — fire the post-turn compaction trigger if configured (`orchestrator.compaction.suggested` / `.applied`). A clean turn ends with `orchestrator.turn.awaiting-input` (the session stays open for the next user turn); `orchestrator.turn.failed` and `orchestrator.turn.cancelled` end error / cancelled turns. (`orchestrator.turn.completed` is defined on the event map, but the v1 lifecycle maps a clean completion to `awaiting-input`, so it isn't emitted on the normal path — subscribe to `awaiting-input` for "turn done".) ## `OrchestratorResult` — read the report ```ts const result = await supportBot.execute(message, { sessionId, history }); result.sessionId; // echoes the session this turn acted on result.turnIndex; // zero-indexed turn number result.data; // validated against `output`, if set result.error; // typed AIError — execute() never throws on runtime failure result.report.type; // "orchestrator" result.report.status; // ReportStatus | "awaiting-input" result.report.turns; // TurnSnapshot[] — current turn + prior, bounded by keepSnapshots result.compaction; // CompactionResult when a turn compacted (and no onCompact ran) ``` `report.children[]` carries ONLY the current turn's dispatched primitive reports. Full session history lives on `report.turns[]` — a `children[]` walker will NOT reach prior turns (intentional). Child `supervisor.*` / `agent.*` events bubble up unmodified under their own identity. A turn callback that calls `agent.execute()` **directly** (not via `ctx.run` / `ctx.intents`) still nests `callback → agent → tool` inside the turn's report tree, with usage rolled up and the session's `sessionId` stamped onto the captured subtree — an ambient `RunFrame` handles the self-attach. See [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md). **`awaiting-input` is the only non-terminal status across the unified result tree.** Code branching on `status === "completed"` MUST explicitly handle `"awaiting-input"` as a session-continues path, not a failure. ## `iterate` — single dispatch vs. internal supervisor - **`iterate: false`** (default) — one dispatch per turn. No `snapshotStore` needed. - **`iterate: true`** — each turn delegates to a real internal supervisor that loops to `maxIterations` (default 10). **Requires** a `snapshotStore` (explicit or `ai.config({ defaultSnapshotStore })`) so a crashed mid-turn iteration can resume. Construction throws if you set `iterate: true` without one. ## `resume()` — drain an interrupted turn ```ts const result = await supportBot.resume("sess_42", { context: { db }, force: false }); ``` Resume continues an interrupted `iterate: true` turn from its persisted supervisor snapshot. Returns `null` when there is nothing in flight for the session (a no-op for `iterate: false` orchestrators). It re-supplies request-scoped `context` (NOT persisted) and rehydrates state from the checkpoint — there is no `history` field, since it continues an in-flight turn rather than opening a fresh one. Runs the same drift check as `execute()`; throws `OrchestratorDriftError` on mismatch unless `{ force: true }`. Use the boot-drain pattern: enumerate sessions via `checkpointStore.list(name)` and `resume()` each on startup. ## Compaction — `summarize` Bounds session history growth. Two forms: ```ts // Object policy — count-based auto-fire after `afterTurns`, keep the most recent `keep`. summarize: { afterTurns: 20, keep: 6, summarizer: cheapModel, // defaults to the orchestrator's own model onCompact: async (compaction, ctx) => { // apply to the dev's message store await messages.applyCompaction(ctx.sessionId, compaction); }, lock: { maxWait: 5_000 }, } // Callback form — full control; NEVER auto-fires, driven only by command("compact"). summarize: (history) => ({ summary, replacesFromIndex, replacesToIndex }), ``` A `CompactionResult` is `{ summary: Message, replacesFromIndex, replacesToIndex }` — the replacement summary plus the inclusive index range it replaces in the dev's history array. When `onCompact` is supplied the orchestrator applies it for you and does NOT surface `result.compaction`; otherwise it surfaces `result.compaction` for you to apply manually. ## `command()` — typed built-ins ```ts const compaction = await supportBot.command("compact", { sessionId, history }); // → { summary, replacesFromIndex, replacesToIndex } ``` v1 ships exactly one built-in command, `compact` (manual compaction outside the auto-trigger; reuses the same compaction code path). User commands attach via module augmentation of `OrchestratorCommands` — declaring extra keys in your own `.d.ts` widens the typed `command<K>` surface without a framework release. ## Per-turn memory — `memory` Wire an `ai.memory()` store so each turn recalls relevant memories before routing and remembers the settled outcome after: ```ts ai.orchestrator({ name: "support", intents, route, memory: mem, // bare MemoryContract — recall + remember w/ defaults // or finer control: memory: { store: mem, recall: { k: 5, threshold: 0.7, tier: "semantic" }, // k: 0 = write-only memory remember: true, // false = read-only (recall, never write) rememberTier: "semantic", scope: "session", // DEFAULT — isolate memories per sessionId injectKey: "memories", // ctx.context[injectKey] holds RecalledMemory[] }, }); ``` **Memory is session-scoped by default (4.15.0).** One store instance backs every session of the orchestrator, so `scope` decides what a turn may read: `"session"` (default) keys recall + write-back to the executing `sessionId`, so one user can never recall another's remembered turns. `"shared"` pools every session into one namespace — the pre-4.15.0 behavior, safe only when every session is trusted to see every other's memories. `(sessionId) => key` derives your own boundary (e.g. a tenant id). Memories written before 4.15.0 are unscoped and are only visible under `scope: "shared"`. Recalled memories land in the per-turn `context` bag under `injectKey` (default `"memories"`) — every route / router / evaluate / dispatch callback reads them at `ctx.context.memories`. Memory never mutates the prompt itself; surfacing it stays explicit. Cancelled / failed turns never remember (they revert), regardless of `remember`. See [`@warlock.js/ai/use-ai-memory/SKILL.md`](@warlock.js/ai/use-ai-memory/SKILL.md). ## `asTool()` — orchestrator as a tool ```ts const supportTool = supportBot.asTool({ name: "handle_refund", description: "Handle a refund conversation end-to-end.", inputSchema: v.object({ message: v.string() }), sessionScope: "fresh", // default — each call gets a brand-new sessionId }); const concierge = ai.agent({ model, tools: [supportTool] }); ``` The tool boundary is **opaque**: the parent's `signal` / `context` / events do NOT auto-forward — anything the wrapped orchestrator needs must ride on the `inputSchema` payload. `sessionScope`: - **`"fresh"`** (default) — each invocation gets a generated `sessionId` and empty history; no continuity across calls. - **`"shared"`** — the orchestrator joins an existing session named by the DEVELOPER through `session`, never by the model: either a literal id fixed at construction (`session: "sess_42"`) or a resolver reading the out-of-band tool context (`session: (ctx) => String(ctx?.artifacts?.supportSessionId)`). Building a `"shared"` tool without `session` throws at construction, and `sessionId` / `history` in the payload are stripped, not honored. A `sessionId` is bearer-equivalent to read/write on that session, so it must not be a model-visible `inputSchema` field: before 4.15.0 it was, and a prompt injection reaching the outer agent could make the nested orchestrator resume, mutate, and echo back a *victim's* conversation. `unsafeAllowModelSessionId: true` restores the old payload path — only for a fully trusted outer context where you verify session ownership yourself. ## Drift detection The orchestrator signature fingerprints: name + intents map + route/router presence + evaluate presence + initialAgent + maxIterations + iterate flag + historyWindow shape. It does NOT aggregate the internal supervisor's signature — internal-supervisor drift surfaces only on `iterate: true` resume via the supervisor's own drift check. On mismatch, `OrchestratorDriftError` (`code: "ORCHESTRATOR_DRIFT"`, `category: "drift"`) is thrown synchronously — nothing dispatches. Recover by discarding the session, migrating the persisted checkpoint, or passing `{ force: true }`. ## 3-tier events Handlers fire definition → instance → per-call, in that order, on every emission: ```ts const orch = ai.orchestrator({ ..., on: { "orchestrator.turn.failed": tier1 } }); // tier 1 — definition const off = orch.on("orchestrator.turn.completed", tier2); // tier 2 — instance await orch.execute(input, { sessionId, history, on: { "orchestrator.drift.checked": tier3 } }); // tier 3 — per-call ``` ## Stores `checkpointStore` (cross-turn session state) and `snapshotStore` (internal-supervisor run state for `iterate: true`) are distinct contracts with distinct factories. See [`@warlock.js/ai/manage-ai-stores/SKILL.md`](@warlock.js/ai/manage-ai-stores/SKILL.md). ## See also - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — the engine each turn delegates to - [`@warlock.js/ai/manage-ai-stores/SKILL.md`](@warlock.js/ai/manage-ai-stores/SKILL.md) — `ai.checkpoint.*` / `ai.snapshot.*` - [`@warlock.js/ai/use-ai-memory/SKILL.md`](@warlock.js/ai/use-ai-memory/SKILL.md) — the `memory` field - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `OrchestratorDriftError` / `OrchestratorConfigError` ## run-planner `@warlock.js/ai/run-planner/SKILL.md` --- name: run-planner description: 'Goal-driven planning with ai.planner({...}) — an LLM GENERATES an ordered execution plan over your registered capabilities (agents / workflows / supervisors / tools), then the planner EXECUTES it, threading each step output into the next, and returns the unified {data, report, usage, error} envelope with report.type "planner". Supports DAG scheduling (dag:true + maxConcurrency off dependsOn), adaptive re-planning (replan:{maxReplans} + the onStep continue/abort/replan directive), and plan-only / approval (mode:"plan-only" → status "awaiting-approval" → approvedPlan). A plan step may delegate via ai.spawnSubAgent({...}) — a GENERAL one-shot-agent helper covered in `@warlock.js/ai/run-ai-agent/SKILL.md`; it is not planner-specific. Triggers: `ai.planner`, `planner.execute`, `spawnSubAgent`, `PlannerConfig`, `PlannerCapability`, `PlannerResult`, `PlannerReport`, `PlannerPlan`, `PlannerStep`, `PlannerStepDirective`, `PlannerPlanInvalidError`, `maxSteps`, `dag`, `maxConcurrency`, `dependsOn`, `replan`, `onStep`, `mode`, `approvedPlan`, `awaiting-approval`, `report.plan`, `report.executedSteps`, `parsedStepCeiling`; ''let the model plan the steps'', ''dynamic plan from a goal'', ''run independent steps in parallel'', ''re-plan when a step fails'', ''generate a plan for approval before running it''; typical import `import { ai } from "@warlock.js/ai"`. Skip: a FIXED known pipeline — `@warlock.js/ai/run-ai-workflow/SKILL.md`; routing one input to a specialist each turn — `@warlock.js/ai/run-supervisor/SKILL.md`; a single model + tools call — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langgraph`, `crewai`.' --- # `ai.planner()` — LLM-generated, then executed, plans A planner turns a free-form **goal** into an ordered **plan** the LLM writes itself (referencing only the capabilities you registered), then runs that plan one step at a time through each capability's own `execute()`. Use it when you do NOT know the steps up front — the model decides the sequence. ## When to reach for it - **`agent`** — one model + tools, single task. No multi-step decomposition. - **`workflow`** — a FIXED pipeline you author by hand (`steps: [...]`). The steps are known at design time. - **`supervisor`** — routes one input to the right specialist each turn; loops on a quality verdict. - **`planner`** — the steps are NOT known in advance. The LLM generates the ordered plan from the goal, then the planner executes it. Sequential by default; opt into **DAG** scheduling, **adaptive re-planning**, and **plan-only / approval** as needed (below). ## Shape ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const research = ai.planner({ name: "research-assistant", model: openai.model({ name: "gpt-4o" }), // the plan-GENERATION brain capabilities: [ { name: "search", description: "Search the web for sources", executable: searchAgent }, { name: "summarize", description: "Summarize text into bullet points", executable: summarizer }, { name: "write", description: "Draft a final report", executable: writerAgent }, ], maxSteps: 6, // soft cap; steps beyond it are recorded as "skipped" — see the parse-time ceiling below }); const { data, report, usage, error } = await research.execute("Compare React vs Vue in 2026"); console.log(report.plan?.summary); // the LLM's one-line strategy for (const step of report.executedSteps) { // forensic, in execution order console.log(step.step.capability, step.status); } ``` - `model` builds an internal planning agent with a generated plan-prompt baked on. **Mutually exclusive** with `planner`. - `planner` lets you bring your own fully-configured planning agent (custom prompt, middleware). The planner injects the plan schema as that agent's per-call `output`. - A `capability` is `{ name, description, executable }`. The `name` is what the LLM references per step; the `description` is what it reads to pick. `executable` is any `ExecutableContract` (agent / workflow / supervisor / tool). ## Execution model 1. **Generate** — the planning agent is asked for a `{ steps, summary? }` plan via a generated schema whose `capability` field is an `enum` of your capability names. Each `PlannerStep` is `{ capability, input, id?, reason?, dependsOn? }`. 2. **Execute** — by default steps run **strictly in array order**; each completed step's output is threaded into the next step's input as "Context from earlier steps". (Set `dag: true` to schedule on `dependsOn` instead — below.) 3. **Finalize** — when `output` is set (factory or per-call), the LAST completed step's structured output is validated into `result.data`. A capability that should feed typed output to the planner's `output` should declare its own `output` schema (the planner reads `data`, falling back to an agent's raw `text`). `report.type === "planner"`; `report.children[]` carries every dispatched capability report (plus the planning trip), with usage rolled up. `report.executedSteps` is the authoritative per-step record (`PlannerStepSnapshot[]`). Lazy capability loading is **deferred** — every capability is fully constructed up front. ### Parse-time step ceiling (4.15.0) `maxSteps` can't be expressed in the strict-mode JSON Schema the planning model is given (no `maxItems`), so a provider/proxy that ignores the prompt's step budget could make the planner deserialize an arbitrarily long `steps[]` array before `PlannerRun`'s tail-truncation logic ever ran — `maxSteps` only trimmed *after* the whole array was already parsed and normalized. Plan validation now enforces a hard **parse-time** ceiling of `maxSteps * 4` (or `100` when the schema is built without a `maxSteps`) and **rejects** — rather than truncates — a plan that exceeds it, surfacing `PlannerPlanInvalidError`. The 4× slack keeps the normal case (a model overshooting "at most N steps" slightly) working exactly as before — that overshoot is still truncated to `skipped` steps at execution time, not rejected at parse time. A plan several times its budget is treated as a malfunction worth surfacing, not a prefix worth silently executing. ## DAG scheduling — `dag: true` + `maxConcurrency` Run independent steps in parallel instead of array-order: ```ts const research = ai.planner({ name: "research", model, capabilities, dag: true, // build a DAG from each step's `id` / `dependsOn` maxConcurrency: 4, // max steps in flight at once. default 4 }); ``` With `dag: true` the planner builds a DAG from step `id` / `dependsOn`, runs each **ready level concurrently** (up to `maxConcurrency`), and feeds each step **only its dependencies' outputs** (not the whole prior transcript). A **cycle** or a `dependsOn` naming an unknown step raises a typed `PlannerPlanInvalidError` **before any step runs**. Default `false` ⇒ the strict array-order loop, byte-for-byte unchanged (where `dependsOn` is advisory-only metadata). ## Adaptive re-planning — `replan: { maxReplans }` + `onStep` When set, a **failed step** (or a `replan` verdict from the `onStep` hook) **revises the REMAINING plan** instead of aborting — re-asking the planning agent for a fresh plan seeded with the executed-step digest plus the feedback. Bounded by `maxReplans`; on exhaustion the run ends with the last failure. ```ts const planner = ai.planner({ name: "adaptive", model, capabilities, replan: { maxReplans: 2 }, }); await planner.execute(goal, { onStep: (snapshot, plan) => { // fired after EACH step settles (both the sequential and the DAG path) if (snapshot.status === "completed" && looksWrong(snapshot.output)) { return { type: "replan", feedback: "The summary missed the pricing section." }; } // return nothing / { type: "continue" } to proceed; { type: "abort" } to stop }, }); ``` The `onStep` directive (`PlannerStepDirective`): - `{ type: "continue" }` (or returning nothing) — proceed. - `{ type: "abort" }` — stop; remaining steps recorded `skipped` (exactly as a failure aborts). - `{ type: "replan"; feedback }` — re-plan the remainder, seeded with the digest + `feedback`. **A `replan` directive with no `replan` config is treated as `continue`** (no-op). Default off ⇒ a failure aborts exactly as before. ## Plan-only / approval — `mode: "plan-only"` + `approvedPlan` Generate (and validate) a plan, return it for human sign-off, then execute the approved plan in a follow-up call: ```ts // 1. Generate WITHOUT executing. const draft = await planner.execute(goal, { mode: "plan-only" }); // draft.report.status === "awaiting-approval"; draft.plan carries the generated PlannerPlan. // 2. (human reviews draft.plan) ... then execute it verbatim. const final = await planner.execute(goal, { approvedPlan: draft.plan! }); ``` - `mode: "plan-only"` generates + validates the plan and returns **without executing** — `report.status === "awaiting-approval"` (a planner-specific NON-terminal status) and `result.plan` carries the generated plan. - `approvedPlan` executes that exact plan, **skipping plan generation entirely**. It is still validated against the **live** capabilities, so a stale plan naming a capability the planner no longer has surfaces a `PlannerPlanInvalidError`. - `mode: "plan-only"` **with** `approvedPlan` is contradictory — `approvedPlan` wins (the plan executes). ## Failure + cancellation `execute()` never throws — failures surface on `result.error`: - **`PlannerPlanInvalidError`** (`PLANNER_PLAN_INVALID`, category `schema`) — empty plan, a step naming an unknown capability, a DAG cycle, a `dependsOn` naming an unknown step, a stale `approvedPlan`, a final-output validation failure, or (4.15.0) a plan exceeding the parse-time step ceiling (`maxSteps * 4`, default `100`). - **`PlannerCancelledError`** (`PLANNER_CANCELLED`, category `cancelled`) — the `AbortSignal` fired. `report.status === "cancelled"`, `report.cancelledAt` set; remaining steps are `skipped`. - A child capability's own error (agent / tool / provider) flows through unchanged on the failing step's snapshot and as `result.error`. The planner stops at the first failed step and marks the rest `skipped`. - **`PlannerFailedError`** is the base for the `PLANNER_*` family. ## Delegating a step with `ai.spawnSubAgent()` A plan step can hand a bounded subtask to a fresh single-use agent with a hard spend cap via `ai.spawnSubAgent({...})`. It is **not** a planner feature — it's a general one-shot-agent helper (a fresh `ai.agent()` + an optional per-task `budget`, run once) that works identically inside a tool, a workflow step, a supervisor intent, or hand-rolled orchestration. The planner engine never calls it; it's simply a primitive a capability *you* write can reach for. Full coverage: [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md). ## Testing Use `MockSDK` for the planning model — script the plan as a JSON string matching `{ steps, summary? }`. Capabilities can be `mockAgent({ name, responses })`. See `src/planner/planner.spec.ts`. ## run-supervisor `@warlock.js/ai/run-supervisor/SKILL.md` --- name: run-supervisor description: 'Multi-intent routing with ai.supervisor({...}) — classifier (iter-0 dispatch), router agent OR route callback, intents as agents / workflows / callbacks, fan-out, evaluate quality loop, ack receptionist, supervisor-level middleware. A callback that calls agent.execute() directly auto-nests agent → tool under the callback span (ambient RunFrame) with usage / cost rolled up — same for team members and orchestrator turns. Triggers: `ai.supervisor`, `ai.router`, `ai.fanOut`, `supervisor.execute`, `supervisor.resume`, `intents`, `router`, `route`, `classifier`, `evaluate`, `ack`, `artifactsSchema`, `middleware`, `END`, `ctx.intents.X.execute`, `ctx.run`, `RunFrame`, `callback span`, `children`, `parentRunId`, `rootRunId`, `trace nesting`, `sub-agent`; ''route one input across specialists'', ''multi-intent dispatch'', ''fan-out then evaluate'', ''classifier then router'', ''supervisor middleware'', ''self-consistency / voting'', ''why is my callback agent not nested / cost is $0'', ''nest a sub-agent under a callback''; typical import `import { ai } from "@warlock.js/ai"`. Skip: durable multi-turn sessions — `@warlock.js/ai/run-orchestrator/SKILL.md`; fixed pipelines — `@warlock.js/ai/run-ai-workflow/SKILL.md`; single agent — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langgraph`, `crewai`.' --- # `ai.supervisor()` — multi-intent routing A supervisor takes one input, picks which intent(s) handle it, runs them, optionally evaluates the result, and either terminates or iterates. Stateless between runs unless you wire `snapshotStore` for resume. ## When to reach for it - **`agent`** — one model + tools, single task. Doesn't fit when the right specialist depends on the input. - **`workflow`** — fixed step order. Doesn't fit when routing decisions need an LLM or vary per request. - **`supervisor`** — when the right specialist is decided per-call and you may iterate to a goal. - **`orchestrator`** — when the *session* matters: long-running conversations with durable cross-turn state, history windowing/compaction, and mid-turn resume. See [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md). ## Three dispatch surfaces | | When it fires | Iterations | | --- | --- | --- | | `classifier` | iter-0 prelude — picks the FIRST intent | 1 | | `router` | iter 0+ (no classifier); iter 1+ (with classifier) | 1..maxIterations | | `route` | iter 0+ (no classifier); iter 1+ (with classifier) | 1..maxIterations | `router` and `route` are mutually exclusive. `classifier` composes with either. Classifier alone (no router/route) → terminates after iter 0. `classifier` is mutually exclusive with `initialAgent`. Quick decision tree: - Pure classification → `classifier` alone. - Multi-step reasoning → `router` + `intents` with rich descriptions. - Deterministic routing → `route` callback. - Classify-then-iterate → `classifier` + `router`/`route`. ## Two routing modes — `route` XOR `router` ### Deterministic — `route(ctx)` ```ts const triageBot = ai.supervisor({ name: "triage", intents: { billing, shipping, returns }, route: (ctx) => { const text = typeof ctx.input === "string" ? ctx.input.toLowerCase() : ""; if (text.includes("refund")) return "billing"; if (text.includes("ship")) return "shipping"; return "returns"; }, }); ``` `route` returns `string | string[] | typeof END`. Array → fan-out. ### LLM-driven — `router` agent ```ts const routerAgent = ai.agent({ output: v.object({ next: v.string(), reasoning: v.string() }), // ... }); const supportBot = ai.supervisor({ router: routerAgent, intents: { billing, shipping, returns, escalate }, evaluate: (ctx) => Object.values(ctx.result).some((b: any) => b.data?.resolved) ? { satisfied: true } : undefined, }); ``` The router agent's output MUST include `next: string | string[] | typeof END`; `reasoning: string` is optional but recommended. `evaluate` pairs with both `router` AND `route` — state-driven termination is useful in either dispatch mode. #### `ai.router()` — skip the boilerplate `ai.router({ model, intents })` builds the routing agent for you: it generates the `{ next, reasoning }` output schema (with the intent names + `END` baked in as a JSON-Schema `enum`) and auto-writes the routing system prompt listing every intent + description. Pass the **same** `intents` object you pass to `ai.supervisor()`. ```ts const intents = { billing, shipping, returns, escalate }; const supportBot = ai.supervisor({ router: ai.router({ model, intents, systemPrompt: "You coordinate a customer-support team.", // optional framing on top }), intents, }); ``` The result is a plain `AgentContract` — usable standalone or as `router`. Hand-writing the agent (above) still works; `ai.router()` is the shortcut. #### `ai.fanOut()` — voting / self-consistency `ai.fanOut(unit, n)` spreads one agent/workflow into `n` distinctly-keyed intent entries (`writer1..writerN`) so the supervisor can dispatch them in parallel and a downstream intent can pick the best/majority answer. Spread it into `intents`: ```ts ai.supervisor({ intents: { ...ai.fanOut(writer, 3), // writer1, writer2, writer3 vote: { run: pickMajority, description: "Choose the majority answer." }, }, route: (ctx) => (ctx.iteration === 0 ? ["writer1", "writer2", "writer3"] : "vote"), }); ``` Each key references the same underlying unit; the description defaults to the unit's. Override the key base with `{ keyPrefix }` and the per-entry text with `{ description }`. #### `maxFanOut` — width cap (default `10`) `maxIterations` bounds how DEEP a run goes; `maxFanOut` bounds how WIDE one decision goes. Duplicate intent names in a fan-out array are collapsed silently (branch results are indexed by intent — duplicates only burn tokens); if the DEDUPED list is still longer than the cap, the decision is rejected with `SupervisorRoutingError` (`SUPERVISOR_INVALID_ROUTE`), same as an unknown intent name. Applies to every dispatch source: `router`, `route`, `evaluate.reassignTo`, `intent.next`. ```ts ai.supervisor({ intents: { ...ai.fanOut(writer, 20), vote }, maxFanOut: 20, route }); ``` Raise it deliberately when you fan out wider than 10. Why it exists: the router's per-turn prompt embeds supervisor `state` and prior branch outputs, so text injected into a tool result can push an LLM router to emit a very wide `next` array — every element a real agent/workflow run, all inside the allowlist. ## The `intents` map — five accepted shapes ```ts intents: { billing: billingAgent, // (a) AgentContract escalate: escalationWorkflow, // (b) WorkflowInstance refund: async (ctx) => ({ refundId: await callRefundAPI(ctx.input) }), // (c) callback triage: { // (d) agent entry agent: triageAgent, description: "First-pass classifier", placeholders: (ctx) => ({ ticket: ctx.input }), output: v.object({ category: v.string() }), }, cancel: { // (e) callback entry run: async (ctx) => ({ cancelledId: await cancelOrder(ctx.input) }), description: "Cancel on customer request", output: v.object({ cancelledId: v.string() }), }, } ``` Runtime detects shape in order: `function → "run" in value → "agent" in value → instanceof`. Mixed dispatch fields (`{ agent, run }` together) throw at construction. **Under a router**, every intent MUST have a non-empty `description` so the LLM has signal. Bare callback shorthand has no description — upgrade to `{ run, description }` under a router. ## State model A supervisor builds up typed `state` across iterations. Each intent contributes a slice; final state validates against the supervisor's `output` schema. ```ts type RefundOutput = { category: string; order?: { id: string }; reply: string }; const refundSupervisor = ai.supervisor<RefundOutput>({ name: "refund-support", output: outputSchema, intents: { classify: { agent: classifierAgent, output: v.object({ category: v.string() }) }, lookupOrder: { run: async (ctx) => ({ order: await ordersRepo.find(extractId(ctx.input)) }), }, compose: { agent: replyAgent, output: v.object({ reply: v.string() }) }, }, router: routerAgent, evaluate: (ctx) => (ctx.state.reply ? { satisfied: true } : undefined), }); ``` Each branch's output strip-merges into state per its declared `output` schema. Last-write-wins on fan-out conflict (warning logged). Keys named `__proto__` / `constructor` / `prototype` are dropped from every merged slice (branch output, `ack`, classifier, `refine`, artifacts) and logged as `state.merge.unsafe-key` — a permissive `output` schema would otherwise let a model-supplied key repoint the run state's prototype. ## Per-intent `next` — skip the router ```ts intents: { classify: { agent: classifierAgent, next: (ctx) => ctx.state.category === "refund" ? "lookupOrder" : "escalate", }, lookupOrder: { run: async (ctx) => ({ order: await ordersRepo.find(extractId(ctx.input)) }), next: (ctx) => ctx.state.order ? "compose" : "escalate", }, compose: { agent: replyAgent, next: () => END }, } ``` Returns: `string` (intent name), `string[]` (fan-out), `END` (terminate), `undefined` (fall back to router). Order of authority: `evaluate` → `intent.next` → `router/route`. ## Stream-mode intents For chat-style prose replies, opt out of structured-output coercion: ```ts intents: { smalltalk: { agent: smalltalkAgent, mode: "stream", streamTo: "reply", // raw text → state.reply }, } ``` Token deltas surface as `supervisor.agent.streaming`. `mode: "stream"` + `output` together throws — they're mutually exclusive. Stream mode is agent-only (workflows can't stream this way). ## `ack` — fast preamble When the router agent / first specialist takes 5+ seconds and users feel it: ```ts ack: (ctx) => ({ ack: "Got it, one moment..." }) // bare callback ack: { run: (ctx) => ({ ack: pickHedge(ctx.input) }), output: v.object({ ack: v.string() }) } ack: { agent: tinyAckAgent, placeholders: (ctx) => ({ tier: ctx.context.customerTier as string }) } ``` Fires on iter-0 only, in parallel with the routing decision. **Same-model trap:** if ack uses the same model+provider as the router, ack often takes longer than the router. The callback forms (1+2) are right for the common case. ## Classifier — `classifier` Iter-0 prelude. Output locked to `{ intent, reasoning?, confidence? }`. ```ts classifier: classifyAgent // or with refine: classifier: { agent: classifyAgent, refine: (ctx) => { const { confidence } = ctx.result.data; if ((confidence ?? 1) < 0.7) return { intent: "fallback" }; return undefined; }, } ``` `refine` shapes: `undefined` (keep), `END` (halt), `{ intent: "x", ...slice }` (override + merge), `{ ...slice }` (keep intent, merge). LLM-reported `confidence` is poorly calibrated — use it as a soft signal alongside heuristics. ## Tool artifacts — `ctx.artifacts` Tools mutate `ctx.artifacts`; supervisor merges into `state` at iteration end. ```ts ai.supervisor({ artifactsSchema: v.object({ blocks: v.array(blockSchema).optional() }), finalizeArtifacts: (state, artifacts) => ({ ...state, blocks: [...(state.blocks ?? []), ...(artifacts.blocks ?? [])], }), }); ``` Default merger — auto-spread (`{...state, ...artifacts}`). `finalizeArtifacts` for concat / dedupe across iterations. Bag resets every iteration. ## Callback intents — `ctx.intents.X.execute()` + `ctx.run` / `ctx.stream` ```ts intents: { "special-refund": async (ctx) => { if ((ctx.input as { amount: number }).amount > 1_000) { await ctx.intents["audit-log"].execute(); // dispatch registered intent } return await callRefundAPI(ctx.input); }, // Inline (non-registered) execution classify: async (ctx) => { const { data } = await ctx.run(classifierAgent, ctx.input); return { category: (data as { label: string }).label }; }, chatInline: async (ctx) => { const stream = ctx.stream(someAgent, enrich(ctx.input)); const final = await stream.result; return { reply: final.text }; }, } ``` Cycle protection: per-branch call stack. Re-entry on same intent → `SUPERVISOR_DISPATCH_CYCLE`. ### Sub-agent trace nesting — `agent.execute()` inside a callback auto-nests A callback that calls `agent.execute()` (or `team` member / `orchestrator` turn callback) **directly** — not through `ctx.run(agent)` / `ctx.intents.X.execute()` — still nests under its enclosing span. An ambient async-local `RunFrame` lets the agent self-attach to the callback's `children[]`, so the report tree is `callback → agent → tool` with usage / cost **rolled up** (no `$0` lone callback span, no manual id threading): ```ts ai.supervisor({ intents: { delegate: async (ctx) => { const result = await worker.execute(String(ctx.input)); // direct call — still nested return { reply: result.text }; }, }, route: (ctx) => (ctx.iteration === 0 ? "delegate" : END), }); // report → callback("delegate") → agent("worker") → tool("echo"); usage flows up to the root. ``` Same behavior across `ai.supervisor`, `ai.team` (member callbacks), and `ai.orchestrator` (turn callbacks) — and `sessionId` propagates onto the captured subtree. `ctx.run(agent)` is captured **exactly once** (the explicit path does not double-count via the ambient frame), and a standalone `agent.execute()` **outside** any callback keeps its own self-root (no frame leakage). This is what an `Observer` / panoptic sees — see [`@warlock.js/ai/observe-ai-flows/SKILL.md`](@warlock.js/ai/observe-ai-flows/SKILL.md). `ai.workflow`'s `run` steps get the same ambient-frame treatment (see [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md#run-step-sub-agent-nesting)) — with one shape difference: workflow has no intermediate "callback" report node, so a `run` step's captured agent lands as a DIRECT child of the workflow report (same tree position `step.agent`'s report already occupies), not nested one level deeper under a step-named node. ## Per-call options ```ts await supportBot.execute(message, { context: { userId, db, traceId }, // request-scoped bag, never persisted history: priorMessages, // Message[] forwarded to router + agents sessionId: "sess_user_42", // stamps onto every report node signal: AbortSignal.timeout(60_000), runId: "support-2026-04-26-7", // for snapshot resume }); ``` `history` precedence: per-call → factory `config.history`. Slice with `historyWindow.{router,agents,ack}` (default ack = 0, router/agents = unbounded) or per-entry `history(ctx)` override. ## Supervisor-level middleware `middleware: AgentMiddleware[]` fires each middleware's optional `supervisor` hook map (`before` / `after` / `onError`) ONCE around the whole `execute()` / `stream()` / `resume()` run: ```ts ai.supervisor({ name: "support", router, intents, middleware: [auditTrail] }); ``` Same onion semantics as the agent pipeline: `before` top-down (return a `SupervisorResult` to short-circuit, throw to abort), `after` / `onError` bottom-up. A middleware without a `supervisor` hook map is skipped — the SAME builtin objects (budget, guardrail, …) can be registered on agents AND here, each declaring whichever level applies. Each needs a unique `name`. See [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md). ## Iteration model 1. Router/route picks `next` (or `END`). 2. Picked intents dispatch (parallel for fan-out). 3. `evaluate` (if provided) inspects results. 4. If satisfied or `END` → terminate. Otherwise → loop. Hard cap via `maxIterations` (default 10). Hitting cap surfaces `MaxIterationsError`. ## Streaming ```ts const stream = supportBot.stream(message); for await (const event of stream) { if (event.type === "supervisor.agent.streaming") { process.stdout.write(event.delta); } } const result = await stream.result; ``` Token-level streaming requires the dispatched agents to be streamed (supervisor calls `agent.stream()` internally). Callbacks don't stream tokens. ## Snapshot resume ```ts import { ai } from "@warlock.js/ai"; import { cache } from "@warlock.js/cache"; ai.config({ defaultStore: cache.driver("redis", { client }) }); await supportBot.execute(message, { runId: "support-7" }); // fresh await supportBot.resume("support-7"); // after crash ``` Signature drift detection throws `SupervisorDriftError` on shape mismatch — `force: true` bypasses. See [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md). ## `asTool()` — supervisor as a tool ```ts const supportTool = supportBot.asTool({ description: "Route a customer support request to the right specialist", inputSchema: v.object({ message: v.string() }), }); const escalationAgent = ai.agent({ model, tools: [supportTool] }); ``` ## Design reference `domains/ai/design/supervisor.md` — full design rationale. ## See also - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — dispatchable units - [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — when steps are known up front - [`@warlock.js/ai/persist-ai-data/SKILL.md`](@warlock.js/ai/persist-ai-data/SKILL.md) — `snapshotStore` + resume - [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) — `semanticCache` fits under each agent's middleware - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — tool artifacts side-channel ## secure-outbound-requests `@warlock.js/ai/secure-outbound-requests/SKILL.md` --- name: secure-outbound-requests description: 'The shared SSRF / resource-exhaustion guard every server-side outbound HTTP request in the framework goes through — `guardedFetch(url, policy, init?)`, `OutboundPolicy`, `assertUrlAllowed`, `fetchTextWithPolicy`, `readTextCapped`. Scheme allowlist (https-only default), host allowlist, post-DNS private/loopback/link-local/metadata-address deny, byte cap, timeout, and (4.15.0) per-hop redirect revalidation with a `maxRedirects` cap and cross-origin credential stripping. Consumed by `ai.rag.loadWeb`, remote text attachments (`prepareAttachmentPart`), and the skills `urlSource` manifest fetch — never a raw `fetch()` on a caller-influenced URL. Triggers: `guardedFetch`, `OutboundPolicy`, `ResolvedOutboundPolicy`, `assertUrlAllowed`, `fetchTextWithPolicy`, `readTextCapped`, `resolveOutboundPolicy`, `OutboundPolicyError`, `maxRedirects`, `denyPrivateIPsAfterDNS`, `hostAllowlist`, `allowedSchemes`, `maxBytes`, `SSRF`, `redirect: "manual"`, `redirect: "error"`; ''SSRF-safe fetch'', ''block a redirect into a private IP'', ''fetch a URL an agent gave me'', ''cap outbound response size'', ''allowlist hosts for outbound requests'', ''strip auth headers on a cross-origin redirect''; typical import `import { guardedFetch, assertUrlAllowed } from "@warlock.js/ai"` (also re-exported per call site). Skip: the RAG loader that wraps this for `loadWeb` — `@warlock.js/ai/rag-loaders-and-stores/SKILL.md`; the skills manifest source that wraps this for `urlSource` — `@warlock.js/ai/use-runtime-skills/SKILL.md`; prompt-injection / content guardrails (a different trust boundary) — `@warlock.js/ai/guard-input-output/SKILL.md` (ai-guard package).' --- # Outbound request policy — the SSRF guard One `OutboundPolicy` + `guardedFetch` backs **every** server-side HTTP request the framework makes on behalf of user/model-controlled input: `ai.rag.loadWeb`, the remote-text branch of `prepareAttachmentPart` (agent `attachments`), and the skills catalog `urlSource` manifest fetch. A single audited guard instead of N ad-hoc `fetch()` call sites. ```ts import { guardedFetch, fetchTextWithPolicy, assertUrlAllowed, OutboundPolicyError } from "@warlock.js/ai"; const response = await guardedFetch("https://docs.example.com/page", { hostAllowlist: ["docs.example.com"], maxBytes: 2_000_000, timeoutMs: 5_000, }); ``` ## Strict-by-default policy Every field is optional; `resolveOutboundPolicy` fills safe defaults, so an untuned call is already hardened: | Field | Default | Guards against | | --- | --- | --- | | `allowedSchemes` | `["https"]` | plaintext / `file:` / `data:` exfil — `http` must be opted in | | `hostAllowlist` | unset (any host) | pinning outbound targets to known hosts, e.g. `docs.example.com` allows `a.docs.example.com` | | `denyPrivateIPsAfterDNS` | `true` | **the SSRF guard itself** — resolves the host through DNS and rejects loopback / private / link-local / unique-local / cloud-metadata (`169.254.169.254`) addresses; a public hostname that resolves inward is caught | | `maxRedirects` | `5` | a redirect chain used to bypass the checks above (4.15.0 — see below) | | `maxBytes` | `5_242_880` (5 MiB) | unbounded response bodies | | `timeoutMs` | `10_000` | a hung/slow endpoint tying up the request | | `signal` | unset | caller-supplied `AbortSignal`, merged with the internal timeout | | `fetch` | global `fetch` | inject a stub for tests, or a wrapper enforcing your own app-level rules | Every violation throws `OutboundPolicyError` with `context` carrying the offending URL/host/address — never a silent fallback. ## Redirects are never delegated to the platform (4.15.0) Before 4.15.0, `assertUrlAllowed` validated only the *initial* URL, then handed the request to `fetch` with automatic redirect following — so a URL that passed validation could `3xx` into a private/metadata address or an off-allowlist host with no re-check. `guardedFetch` now issues **every hop** with `redirect: "manual"` and re-runs the `Location` header through the exact same `assertUrlAllowed` (scheme, host allowlist, post-DNS private-IP deny) before following it: - Capped at `policy.maxRedirects` (default `5`) — the `(maxRedirects + 1)`th hop throws `OutboundPolicyError`. - **Credential headers stripped cross-origin.** `authorization`, `cookie`, `proxy-authorization` are dropped the moment a hop's target origin differs from the current one — a redirect can't exfiltrate credentials meant for the original host. - **Method/body semantics match platform behavior.** `303` — and the legacy convention of `301`/`302` on a non-`GET`/`HEAD` method — re-issue the next hop as a bodyless `GET`. - Pass `init.redirect: "manual"` to get the raw 3xx response back (no following, no throw); `init.redirect: "error"` rejects on any redirect. - The net effect: a redirect can never reach a URL the original request could not have reached directly. ```ts // A caller that wants to inspect redirects itself, unfollowed: const res = await guardedFetch(url, policy, { redirect: "manual" }); if (res.status >= 300 && res.status < 400) { console.log(res.headers.get("location")); } ``` ## Reading the body — `readTextCapped` / `fetchTextWithPolicy` `guardedFetch` returns the raw `Response`; read its body through `readTextCapped(response, maxBytes)` to enforce the cap (a declared `content-length` over the cap fails fast, otherwise the stream is read chunk-by-chunk and aborted the moment the running total exceeds it). `fetchTextWithPolicy(url, policy, init?)` is the one-call convenience — `guardedFetch` + `readTextCapped`, returning `{ ok, status, statusText, text }` (body only read when `ok`). ```ts const { ok, status, text } = await fetchTextWithPolicy(url, { hostAllowlist: ["api.example.com"] }); if (!ok) throw new Error(`fetch failed: ${status}`); ``` ## Who consumes this | Call site | Entry point | Notes | | --- | --- | --- | | RAG web loader | `ai.rag.loadWeb(url, { policy })` | [`@warlock.js/ai/rag-loaders-and-stores/SKILL.md`](@warlock.js/ai/rag-loaders-and-stores/SKILL.md) | | Remote text attachment | `prepareAttachmentPart` via `agent.execute({ attachments })` | default-DENY — requires `attachmentPolicy.allowRemoteFetch: true`; policy travels as `attachmentPolicy.outbound`. URL *image* attachments are handed to the provider as a URL and never fetched server-side, so they carry no SSRF surface here | | Skills catalog manifest | `ai.skills({ sources: [urlSource(url, { policy })] })` | [`@warlock.js/ai/use-runtime-skills/SKILL.md`](@warlock.js/ai/use-runtime-skills/SKILL.md) — the fetched manifest is also runtime-validated record-by-record before being trusted | Each call site passes its own `policy` (or `{}` for the strict defaults) — there is no global policy singleton, so tune per source (e.g. `hostAllowlist` for a known-good docs domain vs. an open web crawl). ## Testing Inject a stubbed `policy.fetch` (`(url, init) => Response`) instead of hitting the network — every consumer above accepts `policy.fetch` all the way through. Regression coverage lives in `src/security/outbound-policy.spec.ts` (redirect-to-metadata/loopback/private block, off-allowlist redirect block, hop cap, credential stripping, clean-redirect follow). ## See also - [`@warlock.js/ai/rag-loaders-and-stores/SKILL.md`](@warlock.js/ai/rag-loaders-and-stores/SKILL.md) — `loadWeb`, the primary consumer - [`@warlock.js/ai/use-runtime-skills/SKILL.md`](@warlock.js/ai/use-runtime-skills/SKILL.md) — `urlSource`'s manifest fetch - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `attachments`, including the remote-text fetch path - [`@warlock.js/ai/handle-ai-errors/SKILL.md`](@warlock.js/ai/handle-ai-errors/SKILL.md) — `OutboundPolicyError` ## transcribe-audio `@warlock.js/ai/transcribe-audio/SKILL.md` --- name: transcribe-audio description: 'Speech-to-text via ai.transcribe({ model: sdk.transcribe({ name }), audio }) — the audio-INPUT verb (Theme I), returning the uniform never-throws { data, error, usage, report } envelope with cost-truth + panoptic observation. Feed it an AudioInput = { base64; mediaType; filename? } — build one with ai.audioFromFile(path) (reads disk, infers media type incl. WhatsApp .ogg/.opus) or ai.audioFromBuffer(bytes, mediaType). Models: OpenAI whisper-1 (verbose_json, per-minute, segments + durationSeconds) or gpt-4o-transcribe (json, per-token). Triggers: `ai.transcribe`, `ai.audioFromFile`, `ai.audioFromBuffer`, `sdk.transcribe`, `openai.transcribe`, `TranscriptionModelContract`, `AudioInput`, `TranscriptionSegment`, `MockTranscriptionModel`; ''speech to text'', ''transcribe audio'', ''voice note to text'', ''WhatsApp voice message'', ''whisper'', ''gpt-4o-transcribe'', ''subtitle segments'', ''audio input''; typical import `import { ai } from "@warlock.js/ai"` + `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: text-to-speech / synthesizing a voice — [[generate-speech]]; competing libs raw `openai.audio.transcriptions.create`, `whisper.cpp`.' --- # Transcribe audio — the speech-to-text verb (`ai.transcribe`) `ai.transcribe()` is the inverse of `ai.speech()` on the modality track (Theme I). Audio-in / text-out, wrapped in the same uniform result contract every executable returns — so transcribing a support voicemail slots into cost dashboards and panoptic traces exactly like an agent run. **Extracting text from an audio file NEEDS AI** — that is the `ai.transcribe` step. The file handling (`ai.audioFromFile` / `ai.audioFromBuffer`) is pure, non-AI **utility** that just packages bytes into an `AudioInput`; it does no I/O to a provider on its own. This is audio **input** (STT). For audio **output** (synthesizing a voice line), see [[generate-speech]]. ## Shape — WhatsApp voice note → text, end to end ```ts import { ai } from "@warlock.js/ai"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); // audioFromFile reads the file + infers the media type from the extension. // .ogg / .opus (Android WhatsApp) and .m4a (iOS) are recognized out of the box. const audio = await ai.audioFromFile("./voice-note.ogg"); const { data, error } = await ai.transcribe({ model: openai.transcribe({ name: "whisper-1" }), audio, language: "en", // BCP-47 hint — improves accuracy + latency }); if (error) console.warn(error.code); // typed AIError else console.log(data.text); // the transcript ``` `TranscriptionModelContract` mirrors `SpeechModelContract` — a peer primitive produced by the adapter's optional `transcribe?()` factory. The model id is **not validated locally** — a non-STT id (`openai.transcribe({ name: "gpt-4o" })`) constructs fine and is forwarded to the provider as given, so it fails as a typed provider error on `result.error`, never as a local throw at construction. ## The `AudioInput` shape + the two builders ```ts type AudioInput = { base64: string; // base64-encoded audio bytes mediaType: string; // IANA type, e.g. "audio/ogg", "audio/mpeg" filename?: string; // helps providers infer the codec from the extension }; // From a file on disk — reads + infers media type (override for extensionless files). const fromDisk = await ai.audioFromFile("./meeting.m4a"); const forced = await ai.audioFromFile("./blob", { mediaType: "audio/ogg" }); // From bytes you already hold (an upload buffer, a downloaded blob) — no I/O, no AI. const fromBytes = ai.audioFromBuffer(uploadBuffer, "audio/ogg", "note.ogg"); ``` Keeping `AudioInput` as inlined base64 + explicit media type makes the verb provider-neutral and serializable — there is no `fs` coupling in core, so the same request can cross a queue or an RPC boundary. ## The result envelope ```ts type TranscriptionResult = { type: "transcription"; data?: { text: string; // full transcript segments?: TranscriptionSegment[]; // timestamped, in verbose mode }; // undefined on failure error?: AIError; // undefined on success — NEVER thrown usage: Usage; // tokens (gpt-4o-transcribe) + cost when priced report: TranscriptionReport; // type:"transcription", model, durationSeconds, lineage }; type TranscriptionSegment = { text: string; start?: number; end?: number }; ``` `segments` and `report.durationSeconds` appear only when the provider returns them (whisper's `verbose_json` mode). Use segments to build subtitles or to jump-to-timestamp in a player. ## Transcribe options (provider-neutral) ```ts await ai.transcribe({ model, audio, language: "en", // BCP-47 hint prompt: "Names: Acme, Zoë", // priming — spelling / style hints format: "verbose_json", // response-format override (segments + duration) signal, // AbortSignal observe: collector, // route the report to an Observer (panoptic) sessionId: "ticket-88", // group into a session for flat cost/trace queries options: { /* provider passthrough */ }, }); ``` ## OpenAI — whisper-1 (per-minute) + gpt-4o-transcribe (per-token) ```ts // whisper-1 — defaults to verbose_json → segments + duration; billed PER MINUTE. const whisper = openai.transcribe({ name: "whisper-1", pricing: { perMinute: 0.006 } }); // gpt-4o-transcribe — defaults to json; billed PER TOKEN like a chat model. const gpt = openai.transcribe({ name: "gpt-4o-transcribe", pricing: { input: 2.5, output: 10 } }); const { data, usage } = await ai.transcribe({ model: whisper, audio }); // data.segments → [{ text, start, end }, …]; usage.cost from report.durationSeconds ``` The adapter wraps the base64 bytes in an uploadable via the SDK's `toFile`, using `audio.filename` (or `"audio"`) and `audio.mediaType` so the codec is declared correctly. ## Cost-truth — one rollup, two metering models `ai.transcribe` fills `usage.cost` so STT spend folds into the **same** `Usage.cost` rollup as text: - **Per-minute** (`whisper-1`): `{ perMinute }` × `(durationSeconds / 60)` → `cost.input`. If the provider didn't report a duration, cost stays **`undefined`** (no guessing). - **Token-metered** (`gpt-4o-transcribe`): `{ input, output }` USD-per-1M-tokens → standard `computeCost` against the returned token usage. Per-minute wins when both are set; an unpriced model leaves `usage.cost` **`undefined`** (honest "cost unknown", never a false zero). ## Pattern — inbound voice-message webhook ```ts const stt = openai.transcribe({ name: "whisper-1" }); async function onVoiceMessage(buffer: Buffer, mediaType: string) { const audio = ai.audioFromBuffer(buffer, mediaType, "inbound.ogg"); const { data, error } = await ai.transcribe({ model: stt, audio, sessionId: "inbox" }); if (error) return replyWith("Sorry, I couldn't understand that audio."); return routeToAgent(data.text); // hand the transcript to an ai.agent for a reply } ``` ## Observability The completed `TranscriptionReport` (with `report.durationSeconds` and cost/latency attributed to `report.model`) routes to any registered `Observer` (panoptic, OTel, …) through the shared `observe` seam — `observe: true` (global), an `Observer` (flow-local), or observe-all. See [[observe-ai-flows]]. Provider faults surface as typed `AIError`s on `result.error`; see [[handle-ai-errors]]. ## Testing `MockTranscriptionModel(name, responses, pricing?)` is a deterministic `TranscriptionModelContract` double — no HTTP. Script text/segments/duration/usage/errors and inspect `model.calls`. `MockSDK({ transcriptionResponses, transcriptionPricing }).transcribe({ name })` wires the same double behind a full adapter. ```ts import { MockTranscriptionModel, transcribe } from "@warlock.js/ai"; const AUDIO = { base64: "QUJD", mediaType: "audio/mpeg", filename: "clip.mp3" }; const model = new MockTranscriptionModel("whisper-1", [{ durationSeconds: 120 }], { perMinute: 0.006 }); const { data, usage, report } = await transcribe({ model, audio: AUDIO }); // data.text → "mock transcript" // usage.cost.input → (120 / 60) * 0.006 report.durationSeconds → 120 // model.calls[0] records { audio, options } for assertions ``` Scripting `[{ error: new ProviderRateLimitError("slow down") }]` drives the never-throws path — `result.error` is the typed error and `result.data` is `undefined`. ## See also - [[generate-speech]] — the inverse verb (`ai.speech`), text → audio - [[generate-images]] — the sibling image-output verb (`ai.image`) - [[observe-ai-flows]] — routing the `TranscriptionReport` to panoptic / OTel - [[handle-ai-errors]] — the typed `AIError` taxonomy on `result.error` ## use-ai-memory `@warlock.js/ai/use-ai-memory/SKILL.md` --- name: use-ai-memory description: 'Agent memory with ai.memory({...}) — a provider-neutral store with FOUR tiers: WORKING (in-run scratch, recalled by recency), SEMANTIC (durable facts by cosine similarity over a @warlock.js/cache vector driver via .similar()), EPISODIC (durable events, similarity blended with recency), and PROCEDURAL (durable how-tos, similarity blended with reinforcement). remember() / recall() / clear(); wire it into ai.orchestrator({ memory }). Triggers: `ai.memory`, `memory.remember`, `memory.recall`, `memory.clear`, `MemoryContract`, `MemoryConfig`, `MemoryItem`, `RecalledMemory`, `MemoryTier`, `SemanticMemoryConfig`, `EpisodicMemoryConfig`, `ProceduralMemoryConfig`, `working`, `semantic`, `episodic`, `procedural`, `defaultTier`, `threshold`, `recencyWeight`, `halfLifeMs`, `reinforcementWeight`, `injectKey`, `maxItems`, `scope`, `RecallOptions.scope`; ''give the agent memory'', ''remember user preferences'', ''semantic recall'', ''per-session working memory'', ''episodic / event memory'', ''procedural / how-to memory'', ''recency-weighted recall'', ''reinforce a procedure'', ''cap working memory size'', ''isolate memory per session/tenant''; typical import `import { ai } from "@warlock.js/ai"`. Skip: orchestrator wiring of the memory — `@warlock.js/ai/run-orchestrator/SKILL.md`; the vector cache driver itself — `@warlock.js/cache/cache-basics/SKILL.md`; embeddings primitive — `@warlock.js/ai/embed-text/SKILL.md`; competing libs `mem0`, `langchain` memory.' --- # `ai.memory()` — agent memory store A single provider-neutral store that holds and retrieves what an agent / orchestrator should remember across turns. Four tiers ship in 4.3.0: - **working** — in-run scratch threaded across turns of one session. Volatile, unscored, recalled in insertion order (recency). On by default, size-bounded (`working: { maxItems }`, default `1000` — see below). - **semantic** — durable *facts* stored as embeddings in a `@warlock.js/cache` driver, retrieved by cosine similarity via the driver's native `.similar()` — the same delegation the `semanticCache` middleware uses. Activates only when you pass `semantic` config. - **episodic** — durable *events*: a timestamped log retrieved by similarity **blended with recency** (recent episodes rank higher). Embedder-backed like semantic; tune with `recencyWeight` + `halfLifeMs`. - **procedural** — durable *how-tos*: learned procedures retrieved by similarity **blended with reinforcement** — re-remembering a procedure increments its use count so well-worn procedures rank higher. Tune with `reinforcementWeight`. > **Still deferred** — decay / forgetting (TTL falloff, eviction). The four tiers above are the full 4.3.0 surface; the `MemoryTier` union widened from `"working" | "semantic"` to add `"episodic" | "procedural"` (a non-breaking change). ## Shape ```ts import { ai } from "@warlock.js/ai"; import { MemoryCacheDriver } from "@warlock.js/cache"; import { OpenAISDK } from "@warlock.js/ai-openai"; const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! }); const store = new MemoryCacheDriver(); store.setOptions({}); const mem = ai.memory({ semantic: { embedder: openai.embedder({ name: "text-embedding-3-small" }), store, // vector-capable CacheDriver namespace: "ai.memory", // key prefix; default "ai.memory" }, defaultTier: "semantic", // tier a remember() item lands in without its own `tier` k: 5, // default recall count threshold: 0.7, // default semantic similarity floor [0,1] }); await mem.remember({ text: "User prefers concise answers." }); const hits = await mem.recall("how should I respond?", { k: 3 }); ``` ## Configuration rules (loud at construction) - **At least one tier must be enabled** — `working` defaults to `true`; `semantic` / `episodic` / `procedural` each activate only when you pass their config. Enabling neither throws (`a memory with no tiers can't store or recall`). - **A vector tier with no store throws now** — pass the tier's `store`, or set `ai.config({ defaultStore })` at boot. Applies to `semantic`, `episodic`, and `procedural`. Resolution happens once at construction, not silently on first use (the same loud-now contract `semanticCache` follows). - **`defaultTier` must reference an enabled tier** — defaults to `"working"`. - Set `working: false` for a durable-only memory (then set `defaultTier` to an enabled vector tier). ## Episodic & procedural tiers Both are durable, embedder-backed tiers wired like `semantic` (`{ embedder, store? }`), but they re-rank by *time* and *use*: ```ts const mem = ai.memory({ episodic: { embedder, store, recencyWeight: 0.3, halfLifeMs: 7 * 24 * 60 * 60 * 1000 }, procedural: { embedder, store, reinforcementWeight: 0.3 }, defaultTier: "episodic", }); await mem.remember({ text: "Refunded order 5821 after a cracked-item complaint.", tier: "episodic" }); await mem.remember({ id: "esc", text: "Escalate refunds over $500 to a human.", tier: "procedural" }); await mem.remember({ id: "esc", text: "Escalate refunds over $500 to a human.", tier: "procedural" }); // reinforce → uses 1→2 ``` - **episodic** — stamps each entry with the remember time and decays its recency on an exponential half-life; at equal similarity a recent episode wins. `recencyWeight: 0` → pure similarity. The similarity `threshold` still gates relevance (recency never surfaces an irrelevant-but-recent episode). `now` is injectable for deterministic tests. - **procedural** — keeps a per-procedure use count; re-remembering (same `id`, or same text → same derived id) **reinforces** it with diminishing returns. Recall is side-effect-free. - Each vector tier defaults to its own namespace (`ai.memory.semantic` / `.episodic` / `.procedural`) so they don't collide on a shared driver; override with `namespace`. ## The three methods ### `remember(items)` ```ts await mem.remember({ text: "User is on the Enterprise plan.", tier: "semantic", metadata: { source: "crm" } }); await mem.remember([{ text: "a" }, { text: "b", tier: "working" }]); // batch ``` A `MemoryItem` is `{ text, tier?, id?, scope?, metadata? }`. `text` is the only required field — it's what gets embedded (semantic) and surfaced back on recall. `tier` defaults to the factory `defaultTier`. Semantic items are embedded + indexed; working items append to the in-run buffer. **Re-remembering an item whose id (explicit or text-derived) already exists overwrites in place rather than duplicating.** `metadata` is an opaque bag round-tripped verbatim onto the recalled memory. `scope` is the ISOLATION key — see below. ### `recall(query, options?)` ```ts const hits = await mem.recall("which plan is the user on?", { k: 5, // cap result count (defaults to factory k) threshold: 0.75, // raise the semantic floor for this call tier: "semantic", // restrict to one tier; omit to query every enabled tier scope: "tenant-42", // isolation key — only memories remembered under this exact scope }); for (const hit of hits) { hit.id; hit.text; hit.tier; hit.score; hit.metadata; } ``` Returns `RecalledMemory[]` scored and ordered by descending relevance. By default queries every enabled tier and merges. `score` is in `[0,1]` for **every** tier — cosine similarity (semantic), a recency proxy (working, most-recent = 1), similarity×recency (episodic), or similarity×reinforcement (procedural) — so a mixed recall set sorts on one field without special-casing the tier. Returns `[]` when nothing clears the threshold — never throws on "no hits". **Memory never mutates the prompt.** `recall()` hands you scored entries; surfacing the recalled text (system prefix, a synthesized "what you remember" block, …) is YOUR call so the injection point stays explicit. ### Isolation — `scope` (4.15.0) One store instance is normally shared by many callers (built once at boot, passed into `ai.orchestrator({ memory })`), so `scope` is what keeps one caller's memories out of another's recall: ```ts await mem.remember({ text: "User A's account email is a@example.com", scope: "user-a" }); await mem.recall("what is my email?", { scope: "user-b" }); // [] — never sees user A await mem.recall("what is my email?", { scope: "user-a" }); // user A's own memories await mem.recall("what is my email?"); // only the UNSCOPED pool ``` - The match is **exact equality**, enforced inside every tier (`working` / `semantic` / `episodic` / `procedural`) before hits are scored, merged, or sliced — not something the caller filters afterward. - Omitting `scope` is **not** a wildcard: an unscoped recall reads only unscoped entries. There is no "all scopes" query. - Identical text under two scopes stays two independent entries (including the procedural tier's reinforcement counter). - `ai.orchestrator({ memory })` sets this automatically from the turn's `sessionId` — see [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md). - `clear(tier?)` is scope-agnostic: it drops the tier for every scope. ### Working-memory cap — `working: { maxItems }` (4.15.0) ```ts const mem = ai.memory({ working: { maxItems: 2_000 }, // default 1000; bare `working: true` also works }); ``` The working tier holds everything it's told in **process** memory for the lifetime of the `memory()` instance — which `ai.orchestrator({ memory })` resolves once and reuses for every session. Before 4.15.0 it had no cap, so a memory-backed orchestrator on the open internet was a cheap memory-exhaustion path: one permanent entry per request, forever. The buffer now evicts on overflow, **FIFO over insertion order, not LRU** — recall on this tier is a pure recency proxy (newest `k`, never reordered), so the oldest entries are exactly the ones a bounded recall would never have returned anyway. `maxItems` is validated as an integer `>= 1` at construction; there is no unbounded setting — "no cap" was the vulnerability, not a configuration choice. Raise it deliberately for a long-lived single-tenant process, and put durable recall in the semantic / episodic tiers (which delegate retention to a `CacheDriver`, not process memory). The bound is **global**, not per-scope — a busy session can push another session's older entries out. That's a recall-quality degradation on a volatile scratch tier, never a disclosure (the `scope` isolation filter above still applies). ### `clear(tier?)` ```ts await mem.clear(); // every tier await mem.clear("working"); // just working — e.g. at session end, keeping durable recall ``` ## Wiring into an orchestrator Pass the store as `ai.orchestrator({ memory })` to recall before each turn's dispatch and remember the settled outcome after. Recalled memories land in `ctx.context[injectKey]` (default `"memories"`). See [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md) for the per-turn `memory` field, `recall.k: 0` (write-only), `remember: false` (read-only), and `rememberTier`. ## Picking a vector driver The semantic tier delegates similarity entirely to the `CacheDriver`: - **Dev / tests** — `new MemoryCacheDriver()` (zero config, O(N) scan; fine up to a few thousand entries). - **Production** — a driver with a real ANN index: `pg` with pgvector, `redis` with RediSearch. Drivers without similarity support throw `CacheUnsupportedError` from `set({ vector })` / `similar()`. See [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md). ## See also - [`@warlock.js/ai/run-orchestrator/SKILL.md`](@warlock.js/ai/run-orchestrator/SKILL.md) — the `memory` field on a session - [`@warlock.js/ai/embed-text/SKILL.md`](@warlock.js/ai/embed-text/SKILL.md) — the embedder the semantic tier needs - [`@warlock.js/ai/attach-ai-middleware/SKILL.md`](@warlock.js/ai/attach-ai-middleware/SKILL.md) — `semanticCache`, the sibling `.similar()` consumer - [`@warlock.js/cache/cache-basics/SKILL.md`](@warlock.js/cache/cache-basics/SKILL.md) — vector driver catalog ## use-runtime-skills `@warlock.js/ai/use-runtime-skills/SKILL.md` --- name: use-runtime-skills description: 'Progressive-disclosure agent skills with ai.skills({...}) and the first-class `skills` option on ai.agent — an always-injected cheap metadata catalog plus an on-demand loadSkill tool, backed by directory / url / store sources. Covers inject ("all" | {select:"semantic",topK,embedder}), maxLoadsPerRun, scope tags, the MockSkillsStore, semantic preload, and the inert-by-default Phase-2 self-authoring (saveSkill + default-DENY review gate → promote). Triggers: `ai.skills`, `SkillsConfig`, `SkillsContract`, `SkillSource`, `SkillInjectMode`, `SkillRecord`, `SkillCatalogEntry`, `loadSkill`, `loadSkillTool`, `saveSkill`, `saveSkillTool`, `SkillReviewGate`, `runReviewGate`, `MockSkillsStore`, `proceduralSkillStore`, `maxLoadsPerRun`, `inject`, `scope`, `review`, the agent `skills:` option; ''give an agent loadable skills'', ''progressive disclosure of instructions'', ''catalog of skills the model pulls on demand'', ''semantic preload of skill bodies'', ''let an agent author and review a skill''; typical import `import { ai } from "@warlock.js/ai"`. Skip: composing static system prompts — `@warlock.js/ai/write-system-prompt/SKILL.md`; durable agent memory tiers — `@warlock.js/ai/use-ai-memory/SKILL.md`; defining callable tools — `@warlock.js/ai/define-ai-tool/SKILL.md`.' --- # `ai.skills()` — runtime skills with progressive disclosure A **skill is text injected into an agent's context — it never runs code.** `ai.skills(config)` builds a `SkillsContract`: the mechanism behind the first-class `skills` agent option. The agent always injects a cheap **metadata catalog** (one line per in-scope skill) and registers a `loadSkill` tool so the model pulls a skill's full **body** only when it needs it (progressive disclosure). Bodies are withheld until loaded — keeping context lean. ## The first-class agent option (the supported way) ```ts import { ai } from "@warlock.js/ai"; const agent = ai.agent({ model: openai.model({ name: "gpt-4o" }), systemPrompt: "You are a build assistant.", skills: { // a SkillsConfig OR an ai.skills(...) instance name: "build-skills", sources: [{ type: "directory", path: "./agent-skills" }], }, }); ``` When `skills` is set the agent owns the runtime flow at execute time: it **prepends the always-injected catalog** (and, under `inject`, the preloaded bodies) in front of your system prompt, auto-registers `loadSkill` (plus `saveSkill` only when a `review` gate is configured), and threads the run id so `maxLoadsPerRun` is enforced per execution. **Omitted ⇒ no skills behavior; the agent runs byte-for-byte as today.** The option accepts a raw `SkillsConfig` (the agent passes it to `skills()` for you) or a pre-built `SkillsContract`. ## Factory config — `SkillsConfig` ```ts const lib = ai.skills({ name: "build-skills", // surfaced in analytics + the catalog block sources: [{ type: "directory", path: "./agent-skills" }], // >= 1; later source wins on name clash inject: { select: "semantic", topK: 2, embedder }, // body-injection policy (see below) maxLoadsPerRun: 4, // cap on loadSkill calls per run. default 5 scope: { tags: ["frontend"] }, // only skills whose tags intersect are catalogued review: { approve, store }, // Phase 2 — absent ⇒ saveSkill is NOT exposed analytics: (event) => track(event), // optional efficacy sink (errors swallowed) }); ``` ### Sources — `SkillSource` (discriminated by `type`, never `kind`) - `{ type: "directory", path }` — reads `path/<folder>/SKILL.md` off disk (lazy `node:fs/promises`). - `{ type: "url", url, headers?, policy?, cacheTtlMs? }` — `urlSource(url, options)` fetches a JSON manifest of skills through the shared `guardedFetch` / `OutboundPolicy` guard (scheme/host allowlist, post-DNS private-IP deny, byte cap, timeout, per-hop redirect revalidation) — never a raw `fetch()`. A remote skill source is a prompt supply chain (bodies flow straight into model context), so every fetched record is also runtime-validated before it can be served. `policy` tunes the guard (e.g. `hostAllowlist`); see [`@warlock.js/ai/secure-outbound-requests/SKILL.md`](@warlock.js/ai/secure-outbound-requests/SKILL.md). The result is cached for the source's lifetime, or `cacheTtlMs` when set. - `{ type: "store", store }` — any `SkillsStoreContract`, e.g. `MockSkillsStore`. Sources merge in order; a later source wins on a name collision. ### Injection — `inject` (`SkillInjectMode`) The metadata catalog is **always** injected (it's cheap). `inject` controls whether any **bodies** are auto-injected up front: - **omitted** (default) — inject NO bodies; the model pulls them via `loadSkill`. Pure progressive disclosure. - `"all"` — inject every body up front (small libraries only). - `{ select: "semantic", topK, embedder?, threshold? }` — embed the run input, rank the catalog by cosine similarity, inject the top-`topK` bodies. Needs an embedder (passed here, or lazily auto-resolved). ## `SkillsContract` surface ```ts interface SkillsContract { readonly name: string; catalog(scopeInput?: string): Promise<SkillCatalogEntry[]>; // cheap metadata, body omitted catalogPrompt(scopeInput?: string): Promise<string>; // catalog rendered as a system block preload(input: string): Promise<SkillRecord[]>; // bodies per `inject`; [] when omitted tools(runId?: string): AgentToolEntry<any, any>[]; // loadSkill always; saveSkill iff review } ``` A `SkillCatalogEntry` is `Pick<SkillRecord, "name"|"description"|"version"|"tags"|"type">` — the **structural omission of `body`** is the type-level guarantee the catalog never carries skill bodies. A `SkillRecord` adds the full `body` plus `type: "authored" | "promoted" | "candidate"`. ## `maxLoadsPerRun` — a budget, not a throw `loadSkill` calls are capped per run (default 5). Exhaustion is an **error RESULT the model self-corrects from**, never a throw — the tool returns `{ error }` and the loop continues. `runId` scopes both the budget and analytics correlation. ## Stores ```ts import { ai, MockSkillsStore } from "@warlock.js/ai"; const store = new MockSkillsStore([ { name: "scaffold", description: "Scaffold a form", version: 1, body: "...", type: "authored" }, ]); const lib = ai.skills({ name: "build", sources: [{ type: "store", store }] }); ``` `MockSkillsStore` is an in-memory `SkillsStoreContract` that ships with the package (construct via `new` — it is a concrete test/utility store, not a factory-fronted primitive). It holds the latest record per name, filters out `candidate`s from `list()` / `load()`, and exposes `saveCandidate` / `promote`. `proceduralSkillStore` is also exported (unifies proven procedural memories with named skills). ## Phase 2 — self-authoring (inert by default) Self-authoring is **gated and OFF unless a `review` gate is wired**: - Without `review`, the `saveSkill` tool is **never registered** — a candidate can never be written, let alone injected. - With `review: { approve, store }`, `saveSkill` writes an **INERT** `type: "candidate"` (`version: 0`), filtered out of every catalog/load until promoted. - The `SkillReviewGate.approve(candidate)` is **default-DENY**: only `{ approve: true }` promotes the candidate to a new audited version (`promote` → `type: "promoted"`, `version + 1`). Anything else — `{ approve: false }`, a malformed result, or a **throw** (fail-closed) — keeps it inert. `runReviewGate(candidate, gate, emit?)` runs this and never throws (a throwing gate is a denial), emitting `promoted` / `denied` analytics events. The three interchangeable approve shapes — a policy fn, a validator agent, a human callback — all reduce to one `Promise<{ approve: boolean; reason? }>`. ## Analytics The optional `analytics` sink fires `catalogued` / `loaded` / `used` / `saved` / `promoted` / `denied` events `{ type, skill, version, runId?, outcome? }`. Errors from the sink are swallowed (mirroring the agent's `onUsage` / `onComplete`), so analytics never crash a run. ## See also - [`@warlock.js/ai/write-system-prompt/SKILL.md`](@warlock.js/ai/write-system-prompt/SKILL.md) — static persona / instruction blocks (vs. dynamic loaded skills) - [`@warlock.js/ai/use-ai-memory/SKILL.md`](@warlock.js/ai/use-ai-memory/SKILL.md) — the procedural memory tier `proceduralSkillStore` unifies with - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent the `skills` option attaches to - [`@warlock.js/ai/secure-outbound-requests/SKILL.md`](@warlock.js/ai/secure-outbound-requests/SKILL.md) — the `guardedFetch` / `OutboundPolicy` guard the `url` source's manifest fetch runs through ## write-system-prompt `@warlock.js/ai/write-system-prompt/SKILL.md` --- name: write-system-prompt description: 'Compose system prompts via ai.systemPrompt() / ai.persona() / ai.instruction() — immutable builders with {{placeholder}} substitution, plus ai.systemPrompt.fromFile(path) to seed from a file read once at construction. Carry identity with .meta({ name, version, description, required }) (a name auto-registers in ai.prompts) and compose with merge(...blocks) / merge(contract) / merge(name, { fromVersion }) (provenance in meta.composedFrom). Triggers: `ai.systemPrompt`, `ai.systemPrompt.fromFile`, `ai.persona`, `ai.instruction`, `SystemPromptBlockContract`, `SystemPromptContract`, `SystemPromptMeta`, `SystemPromptMergeOptions`, `PersonaContract`, `InstructionContract`, `meta`, `merge`, `composedFrom`, `fromVersion`, `placeholders`, `{{placeholder|default}}`, `InvalidRequestError`; ''write a system prompt'', ''compose persona + instructions'', ''prompt from a file'', ''name and version a prompt'', ''merge prompts together'', ''per-call prompt override'', ''mustache placeholder''; typical import `import { ai } from "@warlock.js/ai"`. Skip: the named/versioned prompt registry (register / resolve / tag / diff / export / validate) — `@warlock.js/ai/manage-prompts/SKILL.md`; agent factory wiring — `@warlock.js/ai/run-ai-agent/SKILL.md`; competing libs `langchain` `PromptTemplate`, raw f-strings.' --- # System prompts — immutable builders Three factories — `ai.systemPrompt()`, `ai.persona()`, `ai.instruction()` — compose into the `systemPrompt` option accepted by every agent / workflow step. ## The namespace ```ts import { ai } from "@warlock.js/ai"; ai.systemPrompt(); // empty — chain .persona(), .instruction() onto it ai.systemPrompt("literal text"); // one-shot string form ai.systemPrompt([block1, block2]); // array form — blocks render in declaration order ai.systemPrompt.fromFile(path); // seed from a file read once at construction ai.persona(text); // PersonaContract block ai.instruction(text); // InstructionContract block ``` ## Two shapes, same result ### String form — one-shot ```ts ai.agent({ model, systemPrompt: "You are a concise senior TypeScript engineer.", }); ``` ### Builder form — composable ```ts const prompt = ai.systemPrompt() .persona("You are Alex, a senior TypeScript engineer.") .instruction("Explain things assuming the reader is a Go developer.") .instruction("Always cite the relevant TypeScript handbook section."); const myAgent = ai.agent({ model, systemPrompt: prompt }); ``` ### Array form — explicit order ```ts ai.systemPrompt([ ai.persona("You are Alex, a TypeScript expert."), ai.instruction("Respond in {{language|English}}."), ]); ``` ### From a file — `ai.systemPrompt.fromFile(path)` Read a prompt template from disk ONCE, synchronously, at construction. The file's UTF-8 contents seed one instruction block — so `{{placeholders}}` inside the file resolve at `resolve()` time and the result forks with further `.persona()` / `.instruction()` calls: ```ts const prompt = ai.systemPrompt.fromFile("./prompts/support-agent.md"); const localized = prompt.instruction("Respond in {{language|English}}."); localized.resolve({ language: "Arabic" }); ``` One-shot by design (never re-read on `resolve()`). Throws `InvalidRequestError` when the file can't be read — a path typo fails loudly at construction instead of silently producing an empty prompt. `ai.systemPrompt.fromFile(path)` === `SystemPrompt.fromFile(path)`. ## Block ordering `SystemPrompt` stores `blocks: readonly SystemPromptBlockContract[]` — not separate persona + instructions fields. Rendering honors insertion order. - **Chained `.persona(x)`** — replaces the existing persona in place, or prepends when none exists. Default persona-first layout. - **Chained `.instruction(y)`** — appends. - **Array form** — verbatim. ## Immutability — safe forking Every mutation returns a **new** `SystemPrompt`. The original is never touched: ```ts const base = ai.systemPrompt().persona(alex).instruction(cite); const arabic = base.instruction("Prefer Arabic comments"); // base still has 2 blocks, arabic has 3. Neither affects the other. ``` `Persona` and `Instruction` follow the same rule — their `text` is `readonly`. ## Mustache placeholders `{{key}}` and `{{key|defaultValue}}` substitute at render time: ```ts const prompt = ai.systemPrompt() .persona("You are Alex, a TypeScript expert.") .instruction("Respond in {{language|English}}."); await myAgent.execute("Why use generics?", { placeholders: { language: "Arabic" }, }); ``` Or set defaults on the agent — per-call values override them: ```ts ai.agent({ model, systemPrompt: prompt, placeholders: { language: "Arabic" } }); ``` Substitution works on the **rendered** concatenation of every block, so `{{key}}` inside a persona and inside an instruction both resolve against the same placeholder bag. ## Identity + composition — `.meta()` and `merge()` A prompt carries optional `SystemPromptMeta` — `{ name?, version?, description?, required?, composedFrom? }`. Read it with the no-argument accessor; update it immutably with the one-argument form. **Giving a prompt a `name` auto-registers it in the `ai.prompts` registry** (keyed by `name@version`): ```ts const base = ai.systemPrompt("You are support.", { name: "support", version: "1" }); base.meta(); // → { name: "support", version: "1" } const v2 = base.meta({ version: "2" }); // new builder; original untouched; re-registers under support@2 ``` `merge(...)` folds blocks from another source into a **new** builder — a persona **replaces**, instructions **append**: ```ts // (a) N pre-built blocks in one call const p = ai.systemPrompt().merge(ai.persona("You are Alex."), ai.instruction("Be concise.")); // (b) another prompt contract — its blocks fold in; meta.composedFrom records provenance const merged = ai.systemPrompt("Be terse.").merge(otherPrompt); merged.meta()?.composedFrom; // deterministic source labels, e.g. ["base@2"] // (c) a registered prompt resolved from ai.prompts by name (latest, or a pinned fromVersion) const composed = ai.systemPrompt("You are support.").merge("global", { fromVersion: "1" }); ``` The name / contract / registry-name forms are the registry's composition surface — full coverage (register / resolve / version / tag / diff / validate) in [`@warlock.js/ai/manage-prompts/SKILL.md`](@warlock.js/ai/manage-prompts/SKILL.md). `.validate(options?)` is per-builder sugar over `ai.prompts.validate(this, options)` — the deterministic missing-placeholder check plus an optional Nova-safe LLM-judge. ## Per-call overrides Replace the agent's system prompt for a single run: ```ts await myAgent.execute(input, { systemPrompt: alternativePrompt }); ``` Useful for A/B testing, request-scoped personalization, or turn-by-turn prompt variation. ## Tagged discriminator (not `instanceof`) All blocks implement `SystemPromptBlockContract { readonly type: string; readonly text; resolve() }`. Runtime discrimination uses the string `type` tag (`"persona"`, `"instruction"`, future kinds) — **not** `instanceof`. Why: `instanceof` breaks across duplicate package copies (different `node_modules` trees), realms, bundler scopes. ## Pattern — forking a base prompt ```ts const base = ai.systemPrompt() .persona("You are a support agent for Acme Corp.") .instruction("Cite policy §{{policy}} when denying a refund."); const enterprise = base.instruction("Escalate immediately for Enterprise customers."); const trial = base.instruction("Offer a 14-day extension before closing the ticket."); ``` Three distinct prompts, one common foundation. Base is immutable — safe to share. ## See also - [`@warlock.js/ai/manage-prompts/SKILL.md`](@warlock.js/ai/manage-prompts/SKILL.md) — the `ai.prompts` registry these named prompts auto-register into (resolve / version / tag / diff / export / validate) - [`@warlock.js/ai/refine-prompts/SKILL.md`](@warlock.js/ai/refine-prompts/SKILL.md) — `.refined({ model, criteria, store })`, the prompt compiler: lazily rewrite this builder into a model-optimized version, pinned like a lockfile - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — `systemPrompt` on factory + per-call override - [`@warlock.js/ai/run-ai-workflow/SKILL.md`](@warlock.js/ai/run-ai-workflow/SKILL.md) — per-step agent references inherit their own system prompt