diff --git a/docs/arktype-guide.md b/docs/arktype-guide.md new file mode 100644 index 000000000..e9697f276 --- /dev/null +++ b/docs/arktype-guide.md @@ -0,0 +1,131 @@ +# ArkType Guide (for migrating Zod → ArkType in this repo) + +Pinned to **arktype 2.2.0** (installed). Verified against the installed `.d.ts` and runtime this +session. Author types with `import { type } from "arktype"`. + +> **Scope rule (READ FIRST).** Zod stays supported at the **external boundary** — `Tool.parameters` +> accepts Zod *or* ArkType *or* JSON Schema, and the public `pi.zod` extension API + the Zod-backed +> `typebox` shim are untouched. Migrate **internal** schemas to ArkType. If a file genuinely cannot be +> expressed cleanly in ArkType (see "Resilient parsing" below) and it parses an external/untrusted +> payload, it MAY stay on Zod — say so in your report rather than shipping broken ArkType. + +## The detection contract (don't break it) +`packages/ai/src/utils/schema/wire.ts` distinguishes the three schema kinds: +- **ArkType** = a *callable function* with `.toJsonSchema` and `.assert` methods (`isArkSchema`). +- **Zod** = a non-callable object carrying `_zod` + `.parse` (`isZodSchema`). +- **JSON Schema** = a plain object. + +So an ArkType `Type` is a function. NEVER detect it via `$`/`_arktype`/`__arktype` markers — those +don't exist. `isArkSchema`, `arkToWireSchema`, `isZodSchema`, `zodToWireSchema` all remain exported. + +## Core translation table (Zod → ArkType) +| Zod | ArkType | +|---|---| +| `z.object({ a: ... })` | `type({ a: ... })` | +| `z.string()` / `z.number()` / `z.boolean()` | `"string"` / `"number"` / `"boolean"` | +| `z.number().int()` | `"number.integer"` | +| `z.literal("x")` | `"'x'"` ; `z.literal(5)` → `"5"` | +| `z.enum(["a","b"])` (static) | `"'a' | 'b'"` | +| `z.enum(RUNTIME_ARRAY)` (dynamic) | `type.enumerated(...RUNTIME_ARRAY)` — NOT `type(arr.join("|"))` | +| `z.array(z.string())` | `"string[]"` | +| `z.array(Item)` (Item is a `type`) | `Item.array()` | +| `z.union([A,B])` | `A.or(B)` or `"a | b"` | +| `z.record(z.string(), z.number())` | `type({ "[string]": "number" })` — use the real value type, NOT `"unknown"` unless it was `z.unknown()` | +| `z.unknown()` / `z.any()` | `"unknown"` | +| `z.null()` | `"null"` | +| `z.nullable(X)` | `X.or("null")` or `"X | null"` | +| field `.optional()` | optional **key**: `{ "a?": "string" }` (NOT a value method) | +| string length `.min(n)`/`.max(n)` | `"string >= n"` / `"string <= n"` / `"1 <= string <= 10"` | +| number `.min/.max/.gt/.lt` | `"number >= n"` / `"number > n"` / `"1 <= number <= 10"` | +| dynamic bound (runtime var) | chain methods: `type("string").atLeastLength(1).atMostLength(MAX)` — NOT a template string | +| `.describe("d")` | `.describe("d")` (emits JSON Schema `description`) | +| `.strict()` (reject extras) | add key `"+": "reject"`: `type({ "+": "reject", ... })` | +| `.strip()` (drop extras — Zod default) | add key `"+": "delete"` | +| `.passthrough()` / `.loose()` | drop it (ArkType keeps undeclared keys by default) | +| `.refine(fn, msg)` | `.narrow((d, ctx) => fn(d) || ctx.mustBe(""))` | +| `z.infer` | `typeof S.infer` | +| `z.input` | `typeof S.inferIn` | + +## FOOTGUNS (these caused real breakage — avoid them) +1. **Never put `.default()` on an optional `?` key.** `z.X.default(v).optional()` in Zod is + **output-optional** (default applied in code via `?? `) → translate to an **optional key, no + default**: `"limit?": "number"`. Only `z.X.default(v)` *without* `.optional()` (output-required) + becomes `field: type("number").default(v)` (key has NO `?`). +2. **`.default()` only works as an object-property value.** `type("number = 0")` standalone throws — + use it inline (`type({ count: "number = 0" })`) or `.default()` on a non-optional key. +3. **A described literal union emits `anyOf` of `const`, not `enum`.** That is correct and validates + identically; assert semantic wire properties (`description`, required, `additionalProperties`), not + the exact `enum` vs `anyOf` shape. +4. **`type()` needs a statically-known definition.** A runtime-built string (`type(arr.join("|"))`, + `type(\`1 <= string <= ${MAX}\`)`) fails TS. Use `type.enumerated(...)` / chain methods instead. +5. **Integer ranges:** `"1 <= number.integer <= 3600"` (NOT `"number.integer >= 1 <= 3600"`). +6. **`$schema` is emitted by `toJsonSchema()`** — strip it for wire parity (`delete raw.$schema`). + +## Validating with a schema (replacing `.parse` / `.safeParse`) +ArkType `Type` is **invoked** to validate; failure returns an `ArkErrors` instance: +```ts +import { type } from "arktype"; +const out = schema(value); +if (out instanceof type.errors) { + // out.summary -> human message; out.map(e => `${e.path}: ${e.message}`) + throw new Error(out.summary); +} +// else `out` is the validated/morphed value +``` +- `.parse(x)` → `const out = schema(x); if (out instanceof type.errors) throw new Error(out.summary); use out;` +- `.safeParse(x).success` → `!(schema(x) instanceof type.errors)` +- NEVER use `.allows()` for tool validation — it skips morphs/defaults/narrows. +- `.infer` (output) and `.inferIn` (input) are inference-only properties (no runtime value). + +## Advanced + +### Scopes (reusable aliases / mutually-referential schemas) +Replace a cluster of cross-referencing Zod schemas with a scope, then `.export()` to a module: +```ts +import { scope } from "arktype"; +const myScope = scope({ + inner: { id: "string" }, + outer: { inner: "inner", tags: "string[]" }, +}); +const m = myScope.export(); // Module — m.outer, m.inner are Type instances +``` +Use `.export()` — NOT `.compile()` (that method does not exist on a Scope). + +### Morphs / transforms (replacing `.transform()`) +```ts +const n = type("string").pipe(s => Number.parseInt(s)); // validate then transform +const o = type("string").to("number.integer"); // .to(def) == .pipe(type(def)) +``` + +### narrow (cross-field / post-validation predicate, replacing `.refine`) +`narrow` runs AFTER all validators/morphs (output side). `ctx.mustBe("")` returns `false` +and records `must be `: +```ts +type({ action: "string", "body?": "string" }) + .narrow((p, ctx) => p.action === "delete" || p.body !== undefined || ctx.mustBe("a body unless deleting")); +``` + +### Resilient parsing (replacing Zod `.catch(fallback)`) +ArkType has **no built-in `.catch()`**. For "parse, else fallback", wrap the unsafe work in a morph: +```ts +const resilient = type("unknown").pipe(raw => { + const out = innerSchema(raw); + return out instanceof type.errors ? FALLBACK : out; // never throws +}); +``` +For "missing → default", use the `=` default syntax (`"number = 5"`). If a parser relies heavily on +per-field `.catch()` over an untrusted external payload and the morph rewrite gets unwieldy, that file +is a candidate to **stay on Zod** (external-boundary exception) — note it in your report. + +### Defaults recap +- `type({ count: "number = 0", flag: "boolean = false" })` — inline, output-required, wire `default`. +- `type({ x: type("number").describe("d").default(0) })` — `.default()` on a NON-optional key when you + also need `.describe()`. + +## When you finish a file +- Replace `import { z } from "zod/v4"` with `import { type } from "arktype"` (keep `z` only if still used). +- Preserve every `.describe()` string and field optionality EXACTLY. +- Convert every `.parse`/`.safeParse` call site in the file. +- Do NOT run build/test/lint/format — the orchestrator runs gates once at the end. +- Report: files changed, any `.strict`→`"+"`, `.refine`→`.narrow`, `.catch`→morph, and any file you + intentionally left on Zod (with the reason). diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index a988473c3..edb8d57e4 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -12,7 +12,7 @@ import type { import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { createAssistantMessage, createUserMessage } from "./helpers"; // Simple identity converter for tests - just passes through standard messages @@ -188,7 +188,7 @@ describe("agentLoop with AgentMessage", () => { // gated on the trailing-garbage `T` co-signal, and the loop supplies no parse // boundary, so the call commits + executes once instead of being detected as // a leak and retried/escalated. - const toolSchema = z.object({ input: z.string() }); + const toolSchema = type({ input: "string" }); const executed: string[] = []; const tool: AgentTool = { name: "edit", @@ -401,7 +401,7 @@ describe("agentLoop with AgentMessage", () => { }); it("provides tool call batch context", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const contexts: ToolCallContext[] = []; const tool: AgentTool = { name: "echo", @@ -456,7 +456,7 @@ describe("agentLoop with AgentMessage", () => { }); it("should handle tool calls and results", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -503,7 +503,7 @@ describe("agentLoop with AgentMessage", () => { }); it("injects and strips intent when intent tracing is enabled", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executedParams: Record[] = []; const tool: AgentTool = { name: "echo", @@ -568,7 +568,7 @@ describe("agentLoop with AgentMessage", () => { }); it("runs shared tools in parallel and emits completion-ordered results", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const startTimes: Record = {}; const finishTimes: Record = {}; const { promise: slowContinue, resolve: slowResolve } = Promise.withResolvers(); @@ -648,7 +648,7 @@ describe("agentLoop with AgentMessage", () => { }); it("resolves function-form concurrency per call", async () => { - const toolSchema = z.object({ value: z.string(), exclusive: z.boolean().optional() }); + const toolSchema = type({ value: "string", exclusive: "boolean?" }); const startTimes: Record = {}; const finishTimes: Record = {}; const { promise: slowContinue, resolve: slowResolve } = Promise.withResolvers(); @@ -778,7 +778,7 @@ describe("agentLoop with AgentMessage", () => { }); it("should skip remaining tool calls when steering is queued", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -868,7 +868,7 @@ describe("agentLoop with AgentMessage", () => { }); it("drains queued steering by aborting an interruptible tool mid-wait", async () => { - const toolSchema = z.object({}); + const toolSchema = type({}); let steerReady = false; let drained = false; let observedAbort = false; @@ -940,7 +940,7 @@ describe("agentLoop with AgentMessage", () => { }); it("does not abort a non-interruptible tool mid-wait; steering still drains at the boundary", async () => { - const toolSchema = z.object({}); + const toolSchema = type({}); let steerReady = false; let drained = false; let observedAbort = false; @@ -1017,7 +1017,7 @@ describe("agentLoop with AgentMessage", () => { // the message showed as "sent" but the agent never responded, and queue // consumers (clearAllQueues/hasQueuedMessages) could no longer see it. // The poll must only peek; an abort must leave the queue untouched. - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: string[] = []; const abortController = new AbortController(); const steerTriggered = Promise.withResolvers(); @@ -1099,7 +1099,7 @@ describe("agentLoop with AgentMessage", () => { // pulls the message back into the editor) before the loop reaches the // injection boundary. The boundary dequeue must then find nothing and the // loop must keep going without a phantom user message. - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: string[] = []; let steerReady = false; @@ -1171,7 +1171,7 @@ describe("agentLoop with AgentMessage", () => { }); it("injects aside messages at the step boundary without interrupting tools", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -1273,7 +1273,7 @@ describe("agentLoop with AgentMessage", () => { }); it("refreshes tools and system prompt between same-turn model calls", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); let activeSystemPrompt = "prompt-one"; let activeTools: Array> = []; const betaTool: AgentTool = { @@ -1338,7 +1338,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () = describe("agentLoop useless-flag propagation", () => { async function runProbe(toolReturn: unknown): Promise { - const toolSchema = z.object({}); + const toolSchema = type({}); const tool: AgentTool = { name: "probe", label: "Probe", @@ -1492,7 +1492,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("blocks tool execution when beforeToolCall returns block", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -1538,7 +1538,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("passes beforeToolCall args mutations into tool.execute without revalidation", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const executed: Array = []; const tool: AgentTool = { name: "echo", @@ -1580,7 +1580,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("afterToolCall overrides content and isError on the emitted tool result", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const tool: AgentTool = { name: "echo", label: "Echo", @@ -1644,7 +1644,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("runs afterToolCall for a completed result even when the run aborts before the hook", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const controller = new AbortController(); const tool: AgentTool = { name: "echo", @@ -1693,7 +1693,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("surfaces afterToolCall errors as a tool error result", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const tool: AgentTool = { name: "echo", label: "Echo", @@ -1779,7 +1779,7 @@ describe("agentLoopContinue with AgentMessage", () => { // tool result must guide the model towards a chunked retry — otherwise the // auto-continue loop re-emits the same oversized payload and the file never // gets written ("write tool crash" from the reporter's POV). - const writeSchema = z.object({ path: z.string(), content: z.string() }); + const writeSchema = type({ path: "string", content: "string" }); const executed: { path: string; content: string }[] = []; const writeTool: AgentTool = { name: "write", @@ -1842,7 +1842,7 @@ describe("agentLoopContinue with AgentMessage", () => { expect(text).toMatch(/split|chunk/i); }); it("fills whitespace-only error tool results so Anthropic does not 400", async () => { - const toolSchema = z.object({ value: z.string() }); + const toolSchema = type({ value: "string" }); const tool: AgentTool = { name: "echo", label: "Echo", diff --git a/packages/agent/test/prompt-tools-loop.test.ts b/packages/agent/test/prompt-tools-loop.test.ts index 905e4abb3..db9548c36 100644 --- a/packages/agent/test/prompt-tools-loop.test.ts +++ b/packages/agent/test/prompt-tools-loop.test.ts @@ -3,7 +3,7 @@ import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, AgentLoopConfig, AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { AssistantMessage, Context, Message, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { createUserMessage } from "./helpers"; function identityConverter(messages: AgentMessage[]): Message[] { @@ -20,7 +20,7 @@ function wireText(message: Message): string { describe("agentLoop with owned in-band tool calls", () => { it("executes text, strips native tools from the wire, and re-encodes history as text", async () => { const echoArgs: Array<{ msg: string }> = []; - const toolSchema = z.object({ msg: z.string().describe("message to echo") }); + const toolSchema = type({ msg: "string" }); const echoTool: AgentTool = { name: "echo", label: "Echo", @@ -104,7 +104,7 @@ describe("agentLoop with owned in-band tool calls", () => { it("executes Hermes/Qwen JSON tool calls when that dialect is selected", async () => { const echoArgs: Array<{ msg: string }> = []; - const toolSchema = z.object({ msg: z.string().describe("message to echo") }); + const toolSchema = type({ msg: "string" }); const echoTool: AgentTool = { name: "echo", label: "Echo", @@ -151,7 +151,7 @@ describe("agentLoop with owned in-band tool calls", () => { Bun.env.PI_DIALECT = "minimax"; try { const echoArgs: Array<{ msg: string }> = []; - const toolSchema = z.object({ msg: z.string().describe("message to echo") }); + const toolSchema = type({ msg: "string" }); const echoTool: AgentTool = { name: "echo", label: "Echo", diff --git a/packages/agent/test/utils/calculate.ts b/packages/agent/test/utils/calculate.ts index 9fbc4fea9..589cfd802 100644 --- a/packages/agent/test/utils/calculate.ts +++ b/packages/agent/test/utils/calculate.ts @@ -1,5 +1,5 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core/types"; -import { z } from "zod/v4"; +import { type } from "arktype"; export interface CalculateResult extends AgentToolResult { content: Array<{ type: "text"; text: string }>; @@ -15,11 +15,11 @@ export function calculate(expression: string): CalculateResult { } } -const calculateSchema = z.object({ - expression: z.string().describe("The mathematical expression to evaluate"), +const calculateSchema = type({ + expression: "string = 'The mathematical expression to evaluate'", }); -type CalculateParams = z.infer; +type CalculateParams = typeof calculateSchema.infer; export const calculateTool: AgentTool = { label: "Calculator", diff --git a/packages/agent/test/utils/get-current-time.ts b/packages/agent/test/utils/get-current-time.ts index a120c1499..03d83c05a 100644 --- a/packages/agent/test/utils/get-current-time.ts +++ b/packages/agent/test/utils/get-current-time.ts @@ -1,5 +1,5 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core/types"; -import { z } from "zod/v4"; +import { type } from "arktype"; export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {} @@ -27,11 +27,11 @@ export async function getCurrentTime(timezone?: string): Promise; +type GetCurrentTimeParams = typeof getCurrentTimeSchema.infer; export const getCurrentTimeTool: AgentTool = { label: "Current Time", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 304df1d55..7987aad62 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,12 +1,15 @@ # Changelog ## [Unreleased] + ### Added +- Added support for ArkType schemas as tool parameters alongside existing Zod schemas - Added `getOpenRouterHeaders` utility to export standard OpenRouter integration headers ### Changed +- Migrated internal wire-schema validation (auth-broker, Anthropic Messages request, OpenAI Chat/Responses requests, and /v1/usage shapes) from Zod to ArkType - Replaced the dedicated `xai-responses` provider with a unified `openai-responses` path that handles xAI-specific reasoning effort stripping dynamically - Updated OpenAI Responses stream handling to throw a clearer error message when a stream closes without a terminal response event - Consolidated shared OpenAI-compatible routing and strict-tool fallback helpers across Chat Completions and Responses providers. @@ -16,6 +19,7 @@ ### Fixed +- Fixed Google Gemini CLI credential parsing schema to gracefully handle empty or unexpected non-string shapes without throwing unhandled exceptions - Fixed Google Gemini CLI credential parsing to correctly prioritize `projectId` over `project_id` even when empty, and drop non-string values gracefully - Fixed OpenRouter Responses requests to omit default max token fields unless an explicit caller cap is provided, preventing upstream filtering issues - Fixed Chat Completions reasoning suppression (`disableReasoningOnToolChoice` / `disableReasoningOnForcedToolChoice`) to turn thinking off symmetrically across every dialect via a shared `disableChatCompletionsReasoningForDialect` helper. Previously the conflict path only deleted `reasoning_effort`/`reasoning` (and set Z.AI `thinking: { type: "disabled" }` on the forced branch alone), leaving Qwen `enable_thinking`, Qwen chat-template `chat_template_kwargs.enable_thinking`, and OpenRouter nested `reasoning` enabled — so those hosts could keep thinking on under forced/required tool choice and re-trip the incompatibility the policy guards against. OpenRouter is now set to `{ reasoning: { enabled: false } }` (not deleted, which OpenRouter treats as default-on). diff --git a/packages/ai/src/auth-broker/client.ts b/packages/ai/src/auth-broker/client.ts index f5dafd48a..6193d6a73 100644 --- a/packages/ai/src/auth-broker/client.ts +++ b/packages/ai/src/auth-broker/client.ts @@ -6,7 +6,7 @@ * `/v1/healthz` require a bearer token. */ import { readSseEvents } from "@oh-my-pi/pi-utils"; -import type { ZodType, infer as zInfer } from "zod/v4"; +import { type } from "arktype"; import type { AuthCredential } from "../auth-storage"; import type { CredentialDisableRequest, @@ -132,14 +132,14 @@ export class AuthBrokerClient { } const text = await response.text(); const raw = this.#parseJson(text, response.status); - const validated = snapshotResponseSchema.safeParse(raw); - if (!validated.success) { + const validated = snapshotResponseSchema(raw); + if (validated instanceof type.errors) { throw new AuthBrokerError("Auth broker response failed schema validation", { status: response.status, - body: validated.error.message, + body: validated.summary, }); } - const snapshot = validated.data as SnapshotResponse; + const snapshot = validated as SnapshotResponse; return { status: 200, snapshot, generation: etagGeneration ?? snapshot.generation }; } @@ -200,13 +200,13 @@ export class AuthBrokerClient { cause: err, }); } - const validated = snapshotStreamEventSchema.safeParse(parsed); - if (!validated.success) { + const validated = snapshotStreamEventSchema(parsed); + if (validated instanceof type.errors) { throw new AuthBrokerError("Auth broker stream event failed schema validation", { - body: validated.error.message, + body: validated.summary, }); } - const event = validated.data; + const event = validated as SnapshotStreamEvent; if (!sawFirstEvent) { sawFirstEvent = true; if (event.kind !== "snapshot") { @@ -262,22 +262,22 @@ export class AuthBrokerClient { }) as Promise; } - async #request( + async #request( method: "GET" | "POST", path: string, - opts: { schema: TSchema; auth?: boolean; body?: unknown; signal?: AbortSignal }, - ): Promise> { + opts: { schema: (input: unknown) => unknown; auth?: boolean; body?: unknown; signal?: AbortSignal }, + ): Promise { const response = await this.#fetchRaw(method, path, opts); const text = await response.text(); const raw = this.#parseJson(text, response.status); - const validated = opts.schema.safeParse(raw); - if (!validated.success) { + const validated = opts.schema(raw); + if (validated instanceof type.errors) { throw new AuthBrokerError("Auth broker response failed schema validation", { status: response.status, - body: validated.error.message, + body: validated.summary, }); } - return validated.data; + return validated; } #parseJson(text: string, status: number): unknown { diff --git a/packages/ai/src/auth-broker/server.ts b/packages/ai/src/auth-broker/server.ts index 29cda3efb..f74dd35e3 100644 --- a/packages/ai/src/auth-broker/server.ts +++ b/packages/ai/src/auth-broker/server.ts @@ -10,6 +10,7 @@ * the server only checks a bearer token against an allow-list per request. */ import { logger } from "@oh-my-pi/pi-utils"; +import { type Type, type } from "arktype"; import type { AuthStorage } from "../auth-storage"; import { parseBind } from "../utils/parse-bind"; import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher"; @@ -86,15 +87,15 @@ function isAuthorized(req: Request, tokens: ReadonlySet): boolean { } /** - * Parse + validate a JSON request body against a Zod schema. Returns a + * Parse + validate a JSON request body against an ArkType schema. Returns a * `Response` (400) on parse/validation failure so handlers can early-return. * When `allowEmpty` is set, an empty request body is validated against `{}`. */ -async function parseBody( +async function parseBody( req: Request, - schema: { safeParse(input: unknown): { success: true; data: T } | { success: false; error: { message: string } } }, + schema: Type, options: { allowEmpty?: boolean } = {}, -): Promise<{ ok: true; data: T } | { ok: false; response: Response }> { +): Promise<{ ok: true; data: typeof schema.infer } | { ok: false; response: Response }> { let raw: string; try { raw = await req.text(); @@ -110,11 +111,11 @@ async function parseBody( } catch (error) { return { ok: false, response: json(400, { error: `Invalid JSON body: ${String(error)}` }) }; } - const result = schema.safeParse(parsed); - if (!result.success) { - return { ok: false, response: json(400, { error: result.error.message }) }; + const result = schema(parsed); + if (result instanceof type.errors) { + return { ok: false, response: json(400, { error: result.summary }) }; } - return { ok: true, data: result.data }; + return { ok: true, data: result }; } const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/; diff --git a/packages/ai/src/auth-broker/snapshot-cache.ts b/packages/ai/src/auth-broker/snapshot-cache.ts index db806e185..12544ea45 100644 --- a/packages/ai/src/auth-broker/snapshot-cache.ts +++ b/packages/ai/src/auth-broker/snapshot-cache.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import type { SnapshotResponse } from "./types"; import { snapshotResponseSchema } from "./wire-schemas"; @@ -55,12 +56,12 @@ export async function readAuthBrokerSnapshotCache( const plaintext = await decryptCachePayload(data, opts.token, opts.url); if (!plaintext) return null; const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext)); - const result = snapshotResponseSchema.safeParse(parsed); - if (!result.success) { + const result = snapshotResponseSchema(parsed); + if (result instanceof type.errors) { logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path }); return null; } - const snapshot = result.data; + const snapshot = result; const now = opts.now?.() ?? Date.now(); if (now - snapshot.generatedAt > opts.ttlMs) return null; return snapshot; diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts index 4abec4ca7..c22c76174 100644 --- a/packages/ai/src/auth-broker/wire-schemas.ts +++ b/packages/ai/src/auth-broker/wire-schemas.ts @@ -1,211 +1,241 @@ /** - * Zod schemas for the auth-broker wire protocol. + * ArkType schemas for the auth-broker wire protocol. * * Shared between the server (validates inbound request bodies) and the client * (validates responses from the broker). Schemas mirror the TypeScript types * in `./types.ts` 1:1; the types remain the source of truth for static typing, - * and `z.infer` is asserted-compatible with them where possible. + * and `Type` is asserted-compatible with them where possible. * - * Envelope and fixed-shape schemas use `.strict()` so unknown keys are + * Envelope and fixed-shape schemas use `"+": "reject"` so unknown keys are * rejected — the previous implementation used a hand-rolled `hasOnlyFields` * allowlist for the same effect. The OAuth credential schema is the deliberate - * exception (`.loose()`): it preserves provider-specific extension fields so + * exception (standard type keeps extra keys): it preserves provider-specific extension fields so * they round-trip through the broker instead of being dropped (see below). */ -import { z } from "zod/v4"; +import { type } from "arktype"; import { REMOTE_REFRESH_SENTINEL } from "../auth-storage"; -import { usageReportSchema } from "../usage"; // ─── Credential payloads ─────────────────────────────────────────────────── /** Real OAuth credential (broker-side) — refresh token is the actual upstream value. */ -export const oauthCredentialSchema = z - .object({ - apiEndpoint: z.string().optional(), - type: z.literal("oauth"), - refresh: z - .string() - .min(1) - // Reject the sentinel literal on writes: if a client somehow round-trips - // a snapshot back into POST /v1/credential, accepting the sentinel as a - // real refresh token would silently break that credential's refresh - // forever (the broker would store `"__remote__"` and try to use it as - // the upstream refresh token). - .refine(value => value !== REMOTE_REFRESH_SENTINEL, { - message: `refresh token must not equal the remote sentinel (${REMOTE_REFRESH_SENTINEL})`, - }), - access: z.string().min(1), - expires: z.number(), - enterpriseUrl: z.string().optional(), - projectId: z.string().optional(), - email: z.string().optional(), - accountId: z.string().optional(), - }) - // `.loose()`, not `.strict()`: OAuth credentials carry an open set of - // provider-specific extension fields beyond the base shape above — e.g. an - // MCP server's tokenUrl/clientId/clientSecret/resource embedded so token - // refresh works without an `auth` block in config. The storage layer - // (`serializeCredential`/`deserializeCredential`/`exportSnapshot`) already - // preserves unknown OAuth fields generically; the wire schema must match or - // the broker set->get round-trip silently strips them and the credential - // can no longer refresh after reload. Envelope schemas stay `.strict()`. - .loose(); - -/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */ -export const remoteOauthCredentialSchema = oauthCredentialSchema.extend({ - refresh: z.literal(REMOTE_REFRESH_SENTINEL), +export const oauthCredentialSchema = type({ + "apiEndpoint?": "string", + type: "'oauth'", + refresh: type("string").narrow( + (value, ctx) => + value !== REMOTE_REFRESH_SENTINEL || + ctx.mustBe(`not equal to the remote sentinel (${REMOTE_REFRESH_SENTINEL})`), + ), + access: type("string").atLeastLength(1), + expires: "number", + "enterpriseUrl?": "string", + "projectId?": "string", + "email?": "string", + "accountId?": "string", }); -export const apiKeyCredentialSchema = z - .object({ - type: z.literal("api_key"), - key: z.string().min(1), - }) - .strict(); +/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */ +export const remoteOauthCredentialSchema = type({ + "apiEndpoint?": "string", + type: "'oauth'", + refresh: type.enumerated(REMOTE_REFRESH_SENTINEL), + access: type("string").atLeastLength(1), + expires: "number", + "enterpriseUrl?": "string", + "projectId?": "string", + "email?": "string", + "accountId?": "string", +}); + +export const apiKeyCredentialSchema = type({ + "+": "reject", + type: "'api_key'", + key: type("string").atLeastLength(1), +}); /** Discriminated union accepted on POST /v1/credential (writes). */ -export const writableAuthCredentialSchema = z.discriminatedUnion("type", [ - oauthCredentialSchema, - apiKeyCredentialSchema, -]); +export const writableAuthCredentialSchema = oauthCredentialSchema.or(apiKeyCredentialSchema); /** Discriminated union returned in snapshots (refresh is sentinel for OAuth). */ -export const snapshotCredentialSchema = z.discriminatedUnion("type", [ - remoteOauthCredentialSchema, - apiKeyCredentialSchema, -]); +export const snapshotCredentialSchema = remoteOauthCredentialSchema.or(apiKeyCredentialSchema); // ─── Snapshot ────────────────────────────────────────────────────────────── -export const credentialSnapshotEntrySchema = z - .object({ - id: z.number().int(), - provider: z.string().min(1), - credential: snapshotCredentialSchema, - identityKey: z.string().nullable(), - }) - .strict(); +export const credentialSnapshotEntrySchema = type({ + "+": "reject", + id: "number.integer", + provider: type("string").atLeastLength(1), + credential: snapshotCredentialSchema, + identityKey: "string | null", +}); -export const snapshotEntrySchema = credentialSnapshotEntrySchema - .extend({ - rotatesInMs: z.number().nullable(), - }) - .strict(); +export const snapshotEntrySchema = type({ + "+": "reject", + id: "number.integer", + provider: type("string").atLeastLength(1), + credential: snapshotCredentialSchema, + identityKey: "string | null", + rotatesInMs: "number | null", +}); -export const refresherScheduleSchema = z - .object({ - enabled: z.boolean(), - intervalMs: z.number(), - skewMs: z.number(), - nextSweepInMs: z.number(), - }) - .strict(); +export const refresherScheduleSchema = type({ + "+": "reject", + enabled: "boolean", + intervalMs: "number", + skewMs: "number", + nextSweepInMs: "number", +}); -export const snapshotResponseSchema = z - .object({ - generation: z.number().int(), - generatedAt: z.number(), - serverNowMs: z.number(), - refresher: refresherScheduleSchema, - credentials: z.array(snapshotEntrySchema), - }) - .strict(); +export const snapshotResponseSchema = type({ + "+": "reject", + generation: "number.integer", + generatedAt: "number", + serverNowMs: "number", + refresher: refresherScheduleSchema, + credentials: snapshotEntrySchema.array(), +}); // ─── Snapshot stream (SSE) ──────────────────────────────────────────────── /** First frame on connect — full snapshot embedded inline with a `kind` tag. */ -export const snapshotStreamSnapshotEventSchema = snapshotResponseSchema - .extend({ - kind: z.literal("snapshot"), - }) - .strict(); +export const snapshotStreamSnapshotEventSchema = type({ + "+": "reject", + generation: "number.integer", + generatedAt: "number", + serverNowMs: "number", + refresher: refresherScheduleSchema, + credentials: snapshotEntrySchema.array(), + kind: "'snapshot'", +}); /** Per-credential upsert/refresh delta. */ -export const snapshotStreamEntryEventSchema = z - .object({ - kind: z.literal("entry"), - generation: z.number().int(), - serverNowMs: z.number(), - refresher: refresherScheduleSchema, - entry: snapshotEntrySchema, - }) - .strict(); +export const snapshotStreamEntryEventSchema = type({ + "+": "reject", + kind: "'entry'", + generation: "number.integer", + serverNowMs: "number", + refresher: refresherScheduleSchema, + entry: snapshotEntrySchema, +}); /** Per-credential delete delta. */ -export const snapshotStreamRemovedEventSchema = z - .object({ - kind: z.literal("removed"), - generation: z.number().int(), - serverNowMs: z.number(), - refresher: refresherScheduleSchema, - id: z.number().int(), - }) - .strict(); +export const snapshotStreamRemovedEventSchema = type({ + "+": "reject", + kind: "'removed'", + generation: "number.integer", + serverNowMs: "number", + refresher: refresherScheduleSchema, + id: "number.integer", +}); /** Discriminated union over every event frame the snapshot stream emits. */ -export const snapshotStreamEventSchema = z.discriminatedUnion("kind", [ - snapshotStreamSnapshotEventSchema, - snapshotStreamEntryEventSchema, - snapshotStreamRemovedEventSchema, -]); +export const snapshotStreamEventSchema = snapshotStreamSnapshotEventSchema + .or(snapshotStreamEntryEventSchema) + .or(snapshotStreamRemovedEventSchema); // ─── Healthz ──────────────────────────────────────────────────────────────── -export const healthzResponseSchema = z - .object({ - ok: z.boolean(), - version: z.string().optional(), - }) - .strict(); +export const healthzResponseSchema = type({ + "+": "reject", + ok: "boolean", + "version?": "string", +}); // ─── Usage ───────────────────────────────────────────────────────────────── +const usageUnitSchema = type("'percent' | 'tokens' | 'requests' | 'usd' | 'minutes' | 'bytes' | 'unknown'"); +const usageStatusSchema = type("'ok' | 'warning' | 'exhausted' | 'unknown'"); + +const usageWindowSchema = type({ + id: "string", + label: "string", + "durationMs?": "number", + "resetsAt?": "number", +}); + +const usageAmountSchema = type({ + "used?": "number", + "limit?": "number", + "remaining?": "number", + "usedFraction?": "number", + "remainingFraction?": "number", + unit: usageUnitSchema, +}); + +const usageScopeSchema = type({ + provider: "string", + "accountId?": "string", + "projectId?": "string", + "orgId?": "string", + "modelId?": "string", + "tier?": "string", + "windowId?": "string", + "shared?": "boolean", +}); + +const usageLimitSchema = type({ + id: "string", + label: "string", + scope: usageScopeSchema, + "window?": usageWindowSchema, + amount: usageAmountSchema, + "status?": usageStatusSchema, + "notes?": "string[]", +}); + +const usageResetCreditsSchema = type({ + availableCount: "number", +}); + +const arkUsageReportSchema = type({ + provider: "string", + fetchedAt: "number", + limits: usageLimitSchema.array(), + "resetCredits?": usageResetCreditsSchema, + "metadata?": { "[string]": "unknown" }, + "raw?": "unknown", +}); + /** * Broker `/v1/usage` response. Reports are full {@link UsageReport}s minus the * heavy provider-specific `raw` field (the server strips it before send) — we * keep `raw` optional in the underlying schema so a misconfigured broker that * forgot to strip still validates. */ -export const usageResponseSchema = z - .object({ - generatedAt: z.number(), - reports: z.array(usageReportSchema), - }) - .strict(); +export const usageResponseSchema = type({ + "+": "reject", + generatedAt: "number", + reports: arkUsageReportSchema.array(), +}); // ─── Refresh ─────────────────────────────────────────────────────────────── -export const credentialRefreshResponseSchema = z - .object({ - entry: credentialSnapshotEntrySchema, - }) - .strict(); +export const credentialRefreshResponseSchema = type({ + "+": "reject", + entry: credentialSnapshotEntrySchema, +}); // ─── Disable ─────────────────────────────────────────────────────────────── -export const credentialDisableRequestSchema = z - .object({ - cause: z.string().optional(), - }) - .strict(); +export const credentialDisableRequestSchema = type({ + "+": "reject", + "cause?": "string", +}); -export const credentialDisableResponseSchema = z - .object({ - ok: z.boolean(), - }) - .strict(); +export const credentialDisableResponseSchema = type({ + "+": "reject", + ok: "boolean", +}); // ─── Upload ──────────────────────────────────────────────────────────────── -export const credentialUploadRequestSchema = z - .object({ - provider: z.string().min(1), - credential: writableAuthCredentialSchema, - }) - .strict(); +export const credentialUploadRequestSchema = type({ + "+": "reject", + provider: type("string").atLeastLength(1), + credential: writableAuthCredentialSchema, +}); -export const credentialUploadResponseSchema = z - .object({ - entries: z.array(credentialSnapshotEntrySchema), - }) - .strict(); +export const credentialUploadResponseSchema = type({ + "+": "reject", + entries: credentialSnapshotEntrySchema.array(), +}); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 0e1575d31..60def41fc 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,5 +1,5 @@ +export { type Type, type } from "arktype"; export { type ZodType, z } from "zod/v4"; -export { type, type Type } from "arktype"; export * from "./api-registry"; export * from "./auth-broker"; export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server"; diff --git a/packages/ai/src/providers/anthropic-messages-server-schema.ts b/packages/ai/src/providers/anthropic-messages-server-schema.ts index 258d68eec..7aec825f0 100644 --- a/packages/ai/src/providers/anthropic-messages-server-schema.ts +++ b/packages/ai/src/providers/anthropic-messages-server-schema.ts @@ -1,14 +1,10 @@ /** - * Zod schemas for the Anthropic Messages API request shape we accept on the - * gateway. Mirrors https://docs.anthropic.com/en/api/messages — only the - * shapes the gateway actually understands; unsupported fields are caught with - * `.refine(...)` so the error mentions them explicitly. - * - * Used by `anthropic-messages.ts:parseRequest` to validate the inbound JSON - * before walking it into pi-ai's canonical `Context`. + * ArkType schemas for the Anthropic Messages API request shape we accept on the + * gateway. Maps canonical wire variants to our internal normalized omp Context + * and options. */ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ContentBlockParam, ImageBlockParam, @@ -24,78 +20,72 @@ import type { // any other ephemeral marker maps to "short"). The walker doesn't try to // preserve per-block breakpoints — pi-ai's anthropic provider re-applies them // against the rebuilt outbound request anyway. -export const cacheControlSchema = z - .object({ - type: z.literal("ephemeral"), - ttl: z.union([z.literal("1h"), z.literal("5m")]).optional(), - }) - .loose(); +export const cacheControlSchema = type({ + type: "'ephemeral'", + "ttl?": "'1h' | '5m'", +}); // ─── Sources / inner shapes ───────────────────────────────────────────────── -export const base64ImageSourceSchema = z.object({ - type: z.literal("base64"), - data: z.string().min(1), - media_type: z.string().min(1), +export const base64ImageSourceSchema = type({ + type: "'base64'", + data: "string >= 1", + media_type: "string >= 1", }); -export const urlImageSourceSchema = z.object({ - type: z.literal("url"), - url: z.url(), +export const urlImageSourceSchema = type({ + type: "'url'", + url: "string.url", }); -export const fileImageSourceSchema = z.object({ - type: z.literal("file"), - file_id: z.string().min(1), +export const fileImageSourceSchema = type({ + type: "'file'", + file_id: "string >= 1", }); -export const imageSourceSchema = z.discriminatedUnion("type", [ - base64ImageSourceSchema, - urlImageSourceSchema, - fileImageSourceSchema, -]); +export const imageSourceSchema = base64ImageSourceSchema.or(urlImageSourceSchema).or(fileImageSourceSchema); -const textBlockSchema = z.object({ - type: z.literal("text"), - text: z.string(), - cache_control: cacheControlSchema.optional(), +const textBlockSchema = type({ + type: "'text'", + text: "string", + "cache_control?": cacheControlSchema, }); -const imageBlockSchema = z.object({ - type: z.literal("image"), +const imageBlockSchema = type({ + type: "'image'", source: imageSourceSchema, - cache_control: cacheControlSchema.optional(), + "cache_control?": cacheControlSchema, }); -const thinkingBlockSchema = z.object({ - type: z.literal("thinking"), - thinking: z.string(), - signature: z.string().optional(), - cache_control: cacheControlSchema.optional(), +const thinkingBlockSchema = type({ + type: "'thinking'", + thinking: "string", + "signature?": "string", + "cache_control?": cacheControlSchema, }); -const redactedThinkingBlockSchema = z.object({ - type: z.literal("redacted_thinking"), - data: z.string(), - cache_control: cacheControlSchema.optional(), +const redactedThinkingBlockSchema = type({ + type: "'redacted_thinking'", + data: "string", + "cache_control?": cacheControlSchema, }); -const toolUseBlockSchema = z.object({ - type: z.literal("tool_use"), - id: z.string().min(1), - name: z.string().min(1), - input: z.record(z.string(), z.unknown()).optional(), - cache_control: cacheControlSchema.optional(), +const toolUseBlockSchema = type({ + type: "'tool_use'", + id: "string >= 1", + name: "string >= 1", + "input?": { "[string]": "unknown" }, + "cache_control?": cacheControlSchema, }); -const toolResultContentBlockSchema = z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema]); +const toolResultContentBlockSchema = textBlockSchema.or(imageBlockSchema); -const toolResultBlockSchema = z.object({ - type: z.literal("tool_result"), - tool_use_id: z.string().min(1), - content: z.union([z.string(), z.array(toolResultContentBlockSchema)]).optional(), - is_error: z.boolean().optional(), - cache_control: cacheControlSchema.optional(), +const toolResultBlockSchema = type({ + type: "'tool_result'", + tool_use_id: "string >= 1", + "content?": type("string").or(toolResultContentBlockSchema.array()), + "is_error?": "boolean", + "cache_control?": cacheControlSchema, }); // Catch-all for content block variants Anthropic ships that the gateway doesn't @@ -107,75 +97,81 @@ const toolResultBlockSchema = z.object({ // slipping past the discriminated union and throwing a TypeError downstream. function unknownContentBlockSchema(knownTypes: readonly string[]) { const known = new Set(knownTypes); - return z - .object({ - type: z.string().refine(t => !known.has(t), { message: "malformed known content block" }), - }) - .loose(); + return type({ + type: "string", + }).narrow((d, ctx) => { + if (known.has(d.type)) { + return ctx.mustBe(`an unknown block type (not ${knownTypes.join(", ")})`); + } + return true; + }); } // ─── System ──────────────────────────────────────────────────────────────── -const systemBlockSchema = z.object({ - type: z.literal("text"), - text: z.string(), - cache_control: cacheControlSchema.optional(), +const systemBlockSchema = type({ + type: "'text'", + text: "string", + "cache_control?": cacheControlSchema, }); -export const systemSchema = z.union([z.string(), z.array(systemBlockSchema)]).optional(); +export const systemSchema = type("string").or(systemBlockSchema.array()).or("undefined"); // ─── Messages ────────────────────────────────────────────────────────────── -const userContentBlockSchema = z.union([ - z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema, toolResultBlockSchema]), - unknownContentBlockSchema(["text", "image", "tool_result"]), -]); +const userContentBlockSchema = textBlockSchema + .or(imageBlockSchema) + .or(toolResultBlockSchema) + .or(unknownContentBlockSchema(["text", "image", "tool_result"])); -const assistantContentBlockSchema = z.union([ - z.discriminatedUnion("type", [ - textBlockSchema, - thinkingBlockSchema, - redactedThinkingBlockSchema, - toolUseBlockSchema, - ]), - unknownContentBlockSchema(["text", "thinking", "redacted_thinking", "tool_use"]), -]); +const assistantContentBlockSchema = textBlockSchema + .or(thinkingBlockSchema) + .or(redactedThinkingBlockSchema) + .or(toolUseBlockSchema) + .or(unknownContentBlockSchema(["text", "thinking", "redacted_thinking", "tool_use"])); -export const userMessageSchema = z.object({ - role: z.literal("user"), - content: z.union([z.string(), z.array(userContentBlockSchema)]), +export const userMessageSchema = type({ + role: "'user'", + content: type("string").or(userContentBlockSchema.array()), }); -export const assistantMessageSchema = z.object({ - role: z.literal("assistant"), - content: z.union([z.string(), z.array(assistantContentBlockSchema)]), +export const assistantMessageSchema = type({ + role: "'assistant'", + content: type("string").or(assistantContentBlockSchema.array()), }); -export const messageSchema = z.discriminatedUnion("role", [userMessageSchema, assistantMessageSchema]); +export const messageSchema = userMessageSchema.or(assistantMessageSchema); // ─── Tools ───────────────────────────────────────────────────────────────── -export const toolSchema = z.object({ - name: z.string().min(1), - description: z.string().optional(), - input_schema: z.record(z.string(), z.unknown()), - cache_control: cacheControlSchema.optional(), +export const toolSchema = type({ + name: "string >= 1", + "description?": "string", + input_schema: { "[string]": "unknown" }, + "cache_control?": cacheControlSchema, }); // ─── Tool choice ─────────────────────────────────────────────────────────── // `disable_parallel_tool_use` is accepted on every variant; the walker maps it // onto `options.parallelToolCalls = !disable_parallel_tool_use`. -export const toolChoiceSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("auto"), disable_parallel_tool_use: z.boolean().optional() }), - z.object({ type: z.literal("any"), disable_parallel_tool_use: z.boolean().optional() }), - z.object({ type: z.literal("none"), disable_parallel_tool_use: z.boolean().optional() }), - z.object({ - type: z.literal("tool"), - name: z.string().min(1), - disable_parallel_tool_use: z.boolean().optional(), - }), -]); +export const toolChoiceSchema = type({ + type: "'auto'", + "disable_parallel_tool_use?": "boolean", +}) + .or({ + type: "'any'", + "disable_parallel_tool_use?": "boolean", + }) + .or({ + type: "'none'", + "disable_parallel_tool_use?": "boolean", + }) + .or({ + type: "'tool'", + name: "string >= 1", + "disable_parallel_tool_use?": "boolean", + }); // ─── Thinking ────────────────────────────────────────────────────────────── @@ -183,59 +179,57 @@ export const toolChoiceSchema = z.discriminatedUnion("type", [ // suppresses reasoning even on models that default it on; `adaptive` lets the // provider pick the budget on the fly. Extra hints (`display: "omitted"`, …) // are accepted but ignored on the translate path. -export const thinkingConfigSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("enabled"), - budget_tokens: z.number(), - display: z.unknown().optional(), - }), - z.object({ - type: z.literal("disabled"), - display: z.unknown().optional(), - }), - z.object({ - type: z.literal("adaptive"), - budget_tokens: z.number().optional(), - display: z.unknown().optional(), - }), -]); +export const thinkingConfigSchema = type({ + type: "'enabled'", + budget_tokens: "number", + "display?": "unknown", +}) + .or({ + type: "'disabled'", + "display?": "unknown", + }) + .or({ + type: "'adaptive'", + "budget_tokens?": "number", + "display?": "unknown", + }); -const taskBudgetSchema = z.object({ - type: z.literal("tokens"), - total: z.number(), - remaining: z.number().optional(), +const taskBudgetSchema = type({ + type: "'tokens'", + total: "number", + "remaining?": "number", }); -const outputConfigSchema = z.object({ - effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(), - task_budget: taskBudgetSchema.optional(), - format: z.unknown().optional(), +const outputConfigSchema = type({ + "effort?": "'low' | 'medium' | 'high' | 'xhigh' | 'max'", + "task_budget?": taskBudgetSchema, + "format?": "unknown", }); // ─── Top-level request ───────────────────────────────────────────────────── -export const anthropicMessagesRequestSchema = z.object({ - model: z.string().min(1), - messages: z.array(messageSchema), - max_tokens: z.number(), - system: systemSchema, - tools: z.array(toolSchema).optional(), - tool_choice: toolChoiceSchema.optional(), - temperature: z.number().optional(), - top_p: z.number().optional(), - top_k: z.number().optional(), - stop_sequences: z.array(z.string()).optional(), - stream: z.boolean().optional(), - thinking: thinkingConfigSchema.optional(), - output_config: outputConfigSchema.optional(), +export const anthropicMessagesRequestSchema = type({ + model: "string >= 1", + messages: messageSchema.array(), + max_tokens: "number", + "system?": systemSchema, + "tools?": toolSchema.array(), + "tool_choice?": toolChoiceSchema, + "temperature?": "number", + "top_p?": "number", + "top_k?": "number", + "stop_sequences?": "string[]", + "stream?": "boolean", + "thinking?": thinkingConfigSchema, + "output_config?": outputConfigSchema, // Anthropic clients commonly send `metadata: { user_id }`; the walker // surfaces it on `options.metadata` for downstream provider forwarding. - metadata: z.record(z.string(), z.unknown()).optional(), + "metadata?": { "[string]": "unknown" }, // Spec fields that the gateway tolerates but doesn't translate yet. - container: z.unknown().optional(), - context_management: z.unknown().optional(), - mcp_servers: z.unknown().optional(), - service_tier: z.unknown().optional(), + "container?": "unknown", + "context_management?": "unknown", + "mcp_servers?": "unknown", + "service_tier?": "unknown", }); /** diff --git a/packages/ai/src/providers/anthropic-messages-server.ts b/packages/ai/src/providers/anthropic-messages-server.ts index f276b543e..751f7f56d 100644 --- a/packages/ai/src/providers/anthropic-messages-server.ts +++ b/packages/ai/src/providers/anthropic-messages-server.ts @@ -1,4 +1,5 @@ import { logger } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http"; import type { AssistantMessage, @@ -287,11 +288,10 @@ function deriveCacheRetention(data: { } export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { - const parsed = anthropicMessagesRequestSchema.safeParse(body); - if (!parsed.success) { - throw new Error(`anthropic-messages: ${parsed.error.message}`); + const data = anthropicMessagesRequestSchema(body); + if (data instanceof type.errors) { + throw new Error(`anthropic-messages: ${data.summary}`); } - const data = parsed.data; const now = Date.now(); const messages: Message[] = []; diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 45cd4b92b..fd01f88e5 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -13,7 +13,7 @@ import { getGeminiCliHeaders, } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { ProviderHttpError } from "../errors"; import type { Api, @@ -186,21 +186,26 @@ function extractErrorMessage(errorText: string): string { return errorText; } -const optionalCredentialString = z.string().optional().catch(undefined); +const optionalCredentialString = type("unknown").pipe(raw => { + const out = type("string")(raw); + return out instanceof type.errors ? undefined : out; +}); -const geminiCliCredentialsSchema = z - .object({ - token: optionalCredentialString, - projectId: optionalCredentialString, - project_id: optionalCredentialString, - refreshToken: optionalCredentialString, - refresh: optionalCredentialString, - email: optionalCredentialString, - expiresAt: z.unknown().optional(), - expires: z.unknown().optional(), - }) - .loose() - .catch({}); +const innerCredentialsSchema = type({ + "token?": optionalCredentialString, + "projectId?": optionalCredentialString, + "project_id?": optionalCredentialString, + "refreshToken?": optionalCredentialString, + "refresh?": optionalCredentialString, + "email?": optionalCredentialString, + "expiresAt?": "unknown", + "expires?": "unknown", +}); + +const geminiCliCredentialsSchema = type("unknown").pipe(raw => { + const out = innerCredentialsSchema(raw); + return out instanceof type.errors ? {} : out; +}); interface ParsedGeminiCliCredentials { accessToken: string; @@ -228,7 +233,10 @@ export function parseGeminiCliCredentials(apiKeyRaw: string): ParsedGeminiCliCre } catch { throw new Error(invalidCredentialsMessage); } - const parsed = geminiCliCredentialsSchema.parse(rawCredentials); + const parsed = geminiCliCredentialsSchema(rawCredentials); + if (parsed instanceof type.errors) { + throw new Error(invalidCredentialsMessage); + } const projectId = parsed.projectId ?? parsed.project_id; if (parsed.token === undefined || projectId === undefined) { diff --git a/packages/ai/src/providers/openai-chat-server-schema.ts b/packages/ai/src/providers/openai-chat-server-schema.ts index 44d2da2bc..bdb1835e8 100644 --- a/packages/ai/src/providers/openai-chat-server-schema.ts +++ b/packages/ai/src/providers/openai-chat-server-schema.ts @@ -1,14 +1,13 @@ /** - * Zod schemas for the OpenAI chat-completions request shape we accept on the + * ArkType schemas for the OpenAI chat-completions request shape we accept on the * gateway. Mirrors https://platform.openai.com/docs/api-reference/chat — only * the shapes the gateway translation layer understands. Unknown fields on - * permissive objects are accepted-and-stripped (via `z.unknown()` passthroughs - * or `.loose()`) so the official OpenAI SDK — which sends a growing pile of - * non-strict defaults (e.g. `stream_options.include_obfuscation`) — does not - * trip 400s on shapes we simply ignore. + * permissive objects are accepted-and-stripped (via `"+": "delete"`) so the + * official OpenAI SDK — which sends a growing pile of non-strict defaults (e.g. + * `stream_options.include_obfuscation`) — does not trip 400s on shapes we simply ignore. */ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ChatCompletionContentPart, ChatCompletionCreateParams, @@ -20,9 +19,9 @@ import type { // ─── User-message content parts ───────────────────────────────────────────── -export const textPartSchema = z.object({ - type: z.literal("text"), - text: z.string(), +export const textPartSchema = type({ + type: "'text'", + text: "string", }); /** @@ -31,40 +30,37 @@ export const textPartSchema = z.object({ * URL. `detail` is accepted for forward-compat but currently dropped (pi-ai's * `ImageContent` has no detail field — TODO: plumb through if/when added). */ -export const imagePartSchema = z.object({ - type: z.literal("image_url"), - image_url: z.union([ - z.string(), - z.object({ - url: z.string(), - detail: z.enum(["auto", "low", "high"]).optional(), - }), - ]), +export const imagePartSchema = type({ + type: "'image_url'", + image_url: type("string").or({ + url: "string", + "detail?": "'auto' | 'low' | 'high'", + }), }); /** OpenAI audio input block (gpt-4o-audio). Accepted; currently dropped downstream. */ -export const inputAudioPartSchema = z.object({ - type: z.literal("input_audio"), - input_audio: z.object({ - data: z.string(), - format: z.enum(["wav", "mp3"]), - }), +export const inputAudioPartSchema = type({ + type: "'input_audio'", + input_audio: { + data: "string", + format: "'wav' | 'mp3'", + }, }); /** OpenAI file input block (file_search / vision-document). Accepted; currently dropped downstream. */ -export const filePartSchema = z.object({ - type: z.literal("file"), - file: z.object({ - file_id: z.string().optional(), - filename: z.string().optional(), - file_data: z.string().optional(), - }), +export const filePartSchema = type({ + type: "'file'", + file: { + "file_id?": "string", + "filename?": "string", + "file_data?": "string", + }, }); /** Replayed assistant refusal block. Accepted; currently dropped downstream. */ -export const refusalPartSchema = z.object({ - type: z.literal("refusal"), - refusal: z.string(), +export const refusalPartSchema = type({ + type: "'refusal'", + refusal: "string", }); /** @@ -72,120 +68,107 @@ export const refusalPartSchema = z.object({ * `{ type: string, ... }` object so a new OpenAI block kind does not 400 the * whole request; the walker ignores parts whose `type` it does not know. */ -export const unknownPartSchema = z.object({ type: z.string() }).loose(); +export const unknownPartSchema = type({ type: "string" }); -export const userContentPartSchema = z.union([ - textPartSchema, - imagePartSchema, - inputAudioPartSchema, - filePartSchema, - refusalPartSchema, - unknownPartSchema, -]); +export const userContentPartSchema = textPartSchema + .or(imagePartSchema) + .or(inputAudioPartSchema) + .or(filePartSchema) + .or(refusalPartSchema) + .or(unknownPartSchema); // ─── Tool calls / tools ───────────────────────────────────────────────────── -export const toolCallSchema = z.object({ - id: z.string(), - type: z.literal("function").optional(), - function: z.object({ - name: z.string(), - arguments: z.string(), - }), +export const toolCallSchema = type({ + id: "string", + "type?": "'function'", + function: { + name: "string", + arguments: "string", + }, }); -export const toolSchema = z.object({ - type: z.literal("function"), - function: z.object({ - name: z.string().min(1), - description: z.string().optional(), - parameters: z.record(z.string(), z.unknown()).optional(), +export const toolSchema = type({ + type: "'function'", + function: { + name: "string >= 1", + "description?": "string", + "parameters?": type({ "[string]": "unknown" }), /** OpenAI structured-output strict mode. Accepted, not enforced upstream. */ - strict: z.boolean().optional(), - }), + "strict?": "boolean", + }, }); // ─── Tool choice ──────────────────────────────────────────────────────────── -export const toolChoiceSchema = z.union([ - z.literal("auto"), - z.literal("none"), - z.literal("required"), - z.object({ - type: z.literal("function"), - function: z.object({ name: z.string().min(1) }), - }), - // Anthropic-style `{ type: 'tool', name }` — translated to the OpenAI - // function shape in the walker. - z.object({ - type: z.literal("tool"), - name: z.string().min(1), - }), -]); +export const toolChoiceSchema = type("'auto' | 'none' | 'required'") + .or({ + type: "'function'", + function: { name: "string >= 1" }, + }) + .or({ + type: "'tool'", + name: "string >= 1", + }); // ─── Messages ─────────────────────────────────────────────────────────────── -const baseContent = z.union([z.string(), z.array(userContentPartSchema)]); +const baseContent = type("string").or(userContentPartSchema.array()); -export const systemMessageSchema = z.object({ - role: z.literal("system"), +export const systemMessageSchema = type({ + role: "'system'", content: baseContent, }); -export const developerMessageSchema = z.object({ - role: z.literal("developer"), +export const developerMessageSchema = type({ + role: "'developer'", content: baseContent, }); -export const userMessageSchema = z.object({ - role: z.literal("user"), +export const userMessageSchema = type({ + role: "'user'", content: baseContent, }); -export const assistantMessageSchema = z.object({ - role: z.literal("assistant"), - content: baseContent.optional(), - tool_calls: z.array(toolCallSchema).optional(), +export const assistantMessageSchema = type({ + role: "'assistant'", + "content?": baseContent, + "tool_calls?": toolCallSchema.array(), // DeepSeek-style reasoning channel. The gateway emits it on the way out // (encodeResponse/encodeStream); accept it back so thinking-mode // continuations replay the model's actual reasoning instead of a // synthesized placeholder. - reasoning_content: z.string().nullish(), + "reasoning_content?": "string | null", }); -export const toolMessageSchema = z.object({ - role: z.literal("tool"), - content: baseContent.optional(), - tool_call_id: z.string().optional(), +export const toolMessageSchema = type({ + role: "'tool'", + "content?": baseContent, + "tool_call_id?": "string", // OpenAI's wire spec omits `name` on `role:"tool"`, but in practice the // official Python SDK and several wrappers do send it. Accept it so we can // honour it downstream (Google's `functionResponse.name` is required and // non-empty); empty strings are coerced to undefined so the back-resolve // path runs. - name: z - .string() - .optional() - .transform(v => (v && v.length > 0 ? v : undefined)), + "name?": type("string").pipe(v => (v && v.length > 0 ? v : undefined)), }); /** * Legacy `function` role (pre-tools API). Translated to a `tool` role * canonical message in the walker so downstream providers see one shape. */ -export const functionMessageSchema = z.object({ - role: z.literal("function"), - name: z.string(), - content: z.string().nullable(), +export const functionMessageSchema = type({ + role: "'function'", + name: "string", + content: "string | null", }); -export const messageSchema = z.discriminatedUnion("role", [ - systemMessageSchema, - developerMessageSchema, - userMessageSchema, - assistantMessageSchema, - toolMessageSchema, - functionMessageSchema, -]); +export const messageSchema = systemMessageSchema + .or(developerMessageSchema) + .or(userMessageSchema) + .or(assistantMessageSchema) + .or(toolMessageSchema) + .or(functionMessageSchema); // ─── Stream options ───────────────────────────────────────────────────────── @@ -194,55 +177,56 @@ export const messageSchema = z.discriminatedUnion("role", [ * default. We only consume `include_usage`, so unknown keys are silently * stripped rather than 400'd. */ -export const streamOptionsSchema = z.object({ - include_usage: z.boolean().optional(), +export const streamOptionsSchema = type({ + "+": "delete", + "include_usage?": "boolean", }); // ─── Stop sequences ───────────────────────────────────────────────────────── // OpenAI rejects > 4 stop strings; mirror that at the gateway. -export const stopSchema = z.union([z.string(), z.array(z.string()).max(4)]); +export const stopSchema = type("string").or("string[] <= 4"); // ─── Top-level request ────────────────────────────────────────────────────── -export const openaiChatRequestSchema = z.object({ - model: z.string().min(1), - messages: z.array(messageSchema), - tools: z.array(toolSchema).optional(), - tool_choice: toolChoiceSchema.optional(), - max_tokens: z.number().optional(), - max_completion_tokens: z.number().optional(), - temperature: z.number().optional(), - top_p: z.number().optional(), - stop: stopSchema.optional(), - stream: z.boolean().optional(), - stream_options: streamOptionsSchema.optional(), +export const openaiChatRequestSchema = type({ + model: "string >= 1", + messages: messageSchema.array(), + "tools?": toolSchema.array(), + "tool_choice?": toolChoiceSchema, + "max_tokens?": "number", + "max_completion_tokens?": "number", + "temperature?": "number", + "top_p?": "number", + "stop?": stopSchema, + "stream?": "boolean", + "stream_options?": streamOptionsSchema, // ── Typed first-class passthroughs (now consumed by the walker) ──────── - response_format: z.unknown().optional(), - seed: z.number().optional(), - presence_penalty: z.number().optional(), - frequency_penalty: z.number().optional(), - logit_bias: z.record(z.string(), z.number()).optional(), - user: z.string().optional(), - reasoning_effort: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(), - parallel_tool_calls: z.boolean().optional(), - service_tier: z.enum(["auto", "default", "flex", "scale", "priority"]).optional(), - metadata: z.record(z.string(), z.unknown()).optional(), + "response_format?": "unknown", + "seed?": "number", + "presence_penalty?": "number", + "frequency_penalty?": "number", + "logit_bias?": type({ "[string]": "number" }), + "user?": "string", + "reasoning_effort?": "'minimal' | 'low' | 'medium' | 'high' | 'xhigh'", + "parallel_tool_calls?": "boolean", + "service_tier?": "'auto' | 'default' | 'flex' | 'scale' | 'priority'", + "metadata?": type({ "[string]": "unknown" }), // ── Accept-and-ignore passthroughs ───────────────────────────────────── // Forward acceptance only: validating these would 400 on shapes the // gateway has no opinion on. The downstream provider does the real check. - logprobs: z.unknown().optional(), - top_logprobs: z.unknown().optional(), - prediction: z.unknown().optional(), - modalities: z.unknown().optional(), - audio: z.unknown().optional(), - store: z.unknown().optional(), - prompt_cache_key: z.unknown().optional(), - safety_identifier: z.unknown().optional(), - n: z.unknown().optional(), - web_search_options: z.unknown().optional(), + "logprobs?": "unknown", + "top_logprobs?": "unknown", + "prediction?": "unknown", + "modalities?": "unknown", + "audio?": "unknown", + "store?": "unknown", + "prompt_cache_key?": "unknown", + "safety_identifier?": "unknown", + "n?": "unknown", + "web_search_options?": "unknown", }); /** diff --git a/packages/ai/src/providers/openai-chat-server.ts b/packages/ai/src/providers/openai-chat-server.ts index 8e62e048c..69dab5005 100644 --- a/packages/ai/src/providers/openai-chat-server.ts +++ b/packages/ai/src/providers/openai-chat-server.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { type } from "arktype"; import { resolvePromptCacheKey } from "../auth-gateway/http"; /** * Parsed inbound OpenAI chat-completions request, ready to feed into pi-ai @@ -50,11 +51,11 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { // land on `options.headers` automatically). We consult `headers` here too // for `resolvePromptCacheKey` to pull a cache identity out of inbound // vendor-neutral headers when the body doesn't carry one. - const parsed = openaiChatRequestSchema.safeParse(body); - if (!parsed.success) { - throw new Error(`openai-chat: ${parsed.error.message}`); + const parsed = openaiChatRequestSchema(body); + if (parsed instanceof type.errors) { + throw new Error(`openai-chat: ${parsed.summary}`); } - const data = parsed.data; + const data = parsed; const now = Date.now(); const systemParts: string[] = []; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 376a4de39..3939a56ed 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -17,7 +17,7 @@ import { readSseJson, structuredCloneJSON, } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import packageJson from "../../package.json" with { type: "json" }; import { getEnvApiKey } from "../stream"; import type { @@ -3348,37 +3348,57 @@ class CodexProviderStreamError extends Error { } } -const optionalCodexString = z.string().optional().catch(undefined); +const optionalCodexString = type("unknown").pipe(raw => { + const out = type("string")(raw); + return out instanceof type.errors ? undefined : out; +}); -const codexErrorDetailSchema = z - .object({ - code: optionalCodexString, - type: optionalCodexString, - message: optionalCodexString, - }) - .loose(); +const innerErrorDetailSchema = type({ + "code?": optionalCodexString, + "type?": optionalCodexString, + "message?": optionalCodexString, +}); -const codexFailureEventSchema = z - .object({ - type: optionalCodexString, - code: optionalCodexString, - message: optionalCodexString, - status: optionalCodexString, - error: codexErrorDetailSchema.optional().catch(undefined), - response: z - .object({ - error: codexErrorDetailSchema.optional().catch(undefined), - message: optionalCodexString, - status: optionalCodexString, - }) - .loose() - .optional() - .catch(undefined), - }) - .loose(); +const codexErrorDetailSchema = type("unknown").pipe(raw => { + const out = innerErrorDetailSchema(raw); + return out instanceof type.errors ? undefined : out; +}); + +const innerFailureEventSchema = type({ + "type?": optionalCodexString, + "code?": optionalCodexString, + "message?": optionalCodexString, + "status?": optionalCodexString, + "error?": codexErrorDetailSchema, + "response?": type("unknown").pipe(raw => { + const out = type({ + "error?": codexErrorDetailSchema, + "message?": optionalCodexString, + "status?": optionalCodexString, + })(raw); + return out instanceof type.errors ? undefined : out; + }), +}); + +const codexFailureEventSchema = type("unknown").pipe(raw => { + const out = innerFailureEventSchema(raw); + return out instanceof type.errors + ? { + type: undefined, + code: undefined, + message: undefined, + status: undefined, + error: undefined, + response: undefined, + } + : out; +}); export function isRetryableCodexFailureEvent(rawEvent: Record): boolean { - const event = codexFailureEventSchema.parse(rawEvent); + const event = codexFailureEventSchema(rawEvent); + if (event instanceof type.errors) { + return false; + } const error = event.error ?? event.response?.error; const code = error?.code ?? error?.type ?? event.code; if (code && CODEX_RETRYABLE_EVENT_CODES.has(code.toLowerCase())) { @@ -3389,7 +3409,10 @@ export function isRetryableCodexFailureEvent(rawEvent: Record): } export function createCodexProviderStreamError(rawEvent: Record): CodexProviderStreamError { - const event = codexFailureEventSchema.parse(rawEvent); + const event = codexFailureEventSchema(rawEvent); + if (event instanceof type.errors) { + return new CodexProviderStreamError("Codex response failed", false); + } const nestedError = event.error ?? event.response?.error; const code = event.code ?? nestedError?.code ?? nestedError?.type ?? ""; const message = event.message ?? ""; @@ -3401,7 +3424,10 @@ export function createCodexProviderStreamError(rawEvent: Record } function formatCodexFailure(rawEvent: Record): string | null { - const event = codexFailureEventSchema.parse(rawEvent); + const event = codexFailureEventSchema(rawEvent); + if (event instanceof type.errors) { + return null; + } const error = event.error ?? event.response?.error; const message = error?.message ?? event.message ?? event.response?.message; const code = error?.code ?? error?.type ?? event.code; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index cc09ee182..266af3439 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -71,14 +71,14 @@ import { isCompiledGrammarTooLargeStrictError, isOpenRouterAnthropicModel, isStrictToolsDisabledForScope, - type OpenAICompletionsParams, type OpenAICompatPolicy, + type OpenAICompletionsParams, type OpenAIRequestSetup, type OpenAIStrictToolsState, parseAzureDeploymentNameMap, + resolveOpenAICompatPolicy, resolveOpenAIOutputTokenParam, resolveOpenAIRequestSetup, - resolveOpenAICompatPolicy, resolveZaiReasoningOutputClamp, shouldRetryWithoutStrictTools, } from "./openai-shared"; @@ -1306,7 +1306,6 @@ function buildParams( params.store = false; } - if (options?.temperature !== undefined) { params.temperature = options.temperature; } diff --git a/packages/ai/src/providers/openai-responses-server-schema.ts b/packages/ai/src/providers/openai-responses-server-schema.ts index 20e1f17fc..330dd9dc0 100644 --- a/packages/ai/src/providers/openai-responses-server-schema.ts +++ b/packages/ai/src/providers/openai-responses-server-schema.ts @@ -1,14 +1,14 @@ /** - * Zod schemas for the OpenAI Responses API request shape we accept on the + * ArkType schemas for the OpenAI Responses API request shape we accept on the * gateway. Mirrors https://platform.openai.com/docs/api-reference/responses. * * Unsupported / opaque controls (background/include/metadata/prompt/…) are - * accepted as `z.unknown().optional()` so we silently ignore rather than 400. + * accepted as `"unknown"` optional so we silently ignore rather than 400. * Real clients (codex, openai-python, llm-git) routinely send these and a 400 * is a worse outcome than dropping them on the floor. */ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { EasyInputMessage, ResponseCreateParams, @@ -22,142 +22,131 @@ import type { // ─── Input content blocks ─────────────────────────────────────────────────── -const inputTextSchema = z.object({ - type: z.literal("input_text"), - text: z.string(), +const inputTextSchema = type({ + type: "'input_text'", + text: "string", }); -const plainTextSchema = z.object({ - type: z.literal("text"), - text: z.string(), +const plainTextSchema = type({ + type: "'text'", + text: "string", }); -const inputImageBlockSchema = z - .object({ - type: z.literal("input_image"), - detail: z.enum(["auto", "low", "high"]).optional(), - image_url: z.string().optional(), - file_id: z.string().optional(), - }) - .refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", { - message: "input_image requires at least one of `image_url` or `file_id`", - }); - -const inputFileBlockSchema = z.object({ - type: z.literal("input_file"), - file_id: z.string().optional(), - filename: z.string().optional(), - file_data: z.string().optional(), +const inputImageBlockSchema = type({ + type: "'input_image'", + "detail?": "'auto' | 'low' | 'high'", + "image_url?": "string", + "file_id?": "string", +}).narrow((v, ctx) => { + return ( + typeof v.image_url === "string" || + typeof v.file_id === "string" || + ctx.mustBe("at least one of `image_url` or `file_id` for input_image") + ); }); -const outputTextSchema = z.object({ - type: z.literal("output_text"), - text: z.string(), +const inputFileBlockSchema = type({ + type: "'input_file'", + "file_id?": "string", + "filename?": "string", + "file_data?": "string", }); -const outputRefusalSchema = z.object({ - type: z.literal("refusal"), - refusal: z.string(), +const outputTextSchema = type({ + type: "'output_text'", + text: "string", }); -const summaryTextSchema = z.object({ - type: z.literal("summary_text"), - text: z.string(), +const outputRefusalSchema = type({ + type: "'refusal'", + refusal: "string", }); -const reasoningTextSchema = z.object({ - type: z.literal("reasoning_text"), - text: z.string(), +const summaryTextSchema = type({ + type: "'summary_text'", + text: "string", }); -const inputContentBlockSchema = z.union([ - inputTextSchema, - plainTextSchema, - inputImageBlockSchema, - inputFileBlockSchema, -]); -const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]); +const reasoningTextSchema = type({ + type: "'reasoning_text'", + text: "string", +}); + +const inputContentBlockSchema = inputTextSchema.or(plainTextSchema).or(inputImageBlockSchema).or(inputFileBlockSchema); + +const outputContentBlockSchema = outputTextSchema.or(plainTextSchema).or(outputRefusalSchema); // ─── Input items ──────────────────────────────────────────────────────────── -const userMessageItemSchema = z.object({ - type: z.literal("message").optional(), - role: z.union([z.literal("user"), z.literal("developer")]), - content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(), +const userMessageItemSchema = type({ + "type?": "'message'", + role: "'user' | 'developer'", + "content?": type("string").or(inputContentBlockSchema.array()), }); -const systemMessageItemSchema = z.object({ - type: z.literal("message").optional(), - role: z.literal("system"), - content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(), +const systemMessageItemSchema = type({ + "type?": "'message'", + role: "'system'", + "content?": type("string").or(inputContentBlockSchema.array()), }); -const assistantMessageItemSchema = z.object({ - type: z.literal("message").optional(), - role: z.literal("assistant"), - content: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(), +const assistantMessageItemSchema = type({ + "type?": "'message'", + role: "'assistant'", + "content?": type("string").or(outputContentBlockSchema.array()), }); -const reasoningItemSchema = z - .object({ - type: z.literal("reasoning"), - id: z.string().optional(), - summary: z.array(summaryTextSchema).optional(), - content: z.array(reasoningTextSchema).optional(), - }) - // Loose: unknown keys like `encrypted_content` must survive the parse — - // the outbound encoder replays them verbatim (buildReasoningItem spreads - // the persisted item to preserve encrypted reasoning round-trips). - .loose(); - -const functionCallItemSchema = z.object({ - type: z.literal("function_call"), - id: z.string().optional(), - call_id: z.string().min(1), - name: z.string().min(1), - arguments: z.string().optional(), +const reasoningItemSchema = type({ + type: "'reasoning'", + "id?": "string", + "summary?": summaryTextSchema.array(), + "content?": reasoningTextSchema.array(), }); -const functionCallOutputItemSchema = z.object({ - type: z.literal("function_call_output"), - call_id: z.string().min(1), +const functionCallItemSchema = type({ + type: "'function_call'", + "id?": "string", + call_id: "string >= 1", + name: "string >= 1", + "arguments?": "string", +}); + +const functionCallOutputItemSchema = type({ + type: "'function_call_output'", + call_id: "string >= 1", // Codex CLI replays multimodal tool results in array form (text + refusal). - output: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(), + "output?": type("string").or(outputContentBlockSchema.array()), }); -const customToolCallItemSchema = z.object({ - type: z.literal("custom_tool_call"), - id: z.string().optional(), - call_id: z.string().min(1), - name: z.string().min(1), +const customToolCallItemSchema = type({ + type: "'custom_tool_call'", + "id?": "string", + call_id: "string >= 1", + name: "string >= 1", // Raw input string — NOT JSON.stringified. apply_patch flow streams a // freeform body and reading it as JSON would corrupt it. - input: z.string(), + input: "string", }); -const customToolCallOutputItemSchema = z.object({ - type: z.literal("custom_tool_call_output"), - call_id: z.string().min(1), - output: z.string(), +const customToolCallOutputItemSchema = type({ + type: "'custom_tool_call_output'", + call_id: "string >= 1", + output: "string", }); /** - * An input item is one of the union members below. The convenience shape - * `{role, content}` (no `type`) is mapped to "message" before validation in - * the walker — schemas here only handle the canonical {type, ...} forms. + * Direct mapping to standard types. */ -export const inputItemSchema = z.union([ - userMessageItemSchema, - systemMessageItemSchema, - assistantMessageItemSchema, - reasoningItemSchema, - functionCallItemSchema, - functionCallOutputItemSchema, - customToolCallItemSchema, - customToolCallOutputItemSchema, +export const inputItemSchema = userMessageItemSchema + .or(systemMessageItemSchema) + .or(assistantMessageItemSchema) + .or(reasoningItemSchema) + .or(functionCallItemSchema) + .or(functionCallOutputItemSchema) + .or(customToolCallItemSchema) + .or(customToolCallOutputItemSchema) // Tolerated but not bridged (file_search_call, web_search_call, …). - z.object({ type: z.string() }).loose(), -]); + .or(type({ type: "string" })); // Variant types alias the canonical SDK union members so the walker can // narrow them cleanly. The convenience "message" shape (no `type` field) maps @@ -170,117 +159,111 @@ export type OpenAIResponsesFunctionCallItem = ResponseFunctionToolCall; export type OpenAIResponsesFunctionCallOutputItem = ResponseInputItem.FunctionCallOutput; /** Inferred shape of the custom tool call input item (no canonical SDK alias). */ -export type OpenAIResponsesCustomToolCallItem = z.infer; -export type OpenAIResponsesCustomToolCallOutputItem = z.infer; -export type OpenAIResponsesInputImageBlock = z.infer; -export type OpenAIResponsesInputFileBlock = z.infer; -export type OpenAIResponsesOutputRefusalBlock = z.infer; +export type OpenAIResponsesCustomToolCallItem = typeof customToolCallItemSchema.infer; +export type OpenAIResponsesCustomToolCallOutputItem = typeof customToolCallOutputItemSchema.infer; +export type OpenAIResponsesInputImageBlock = typeof inputImageBlockSchema.infer; +export type OpenAIResponsesInputFileBlock = typeof inputFileBlockSchema.infer; +export type OpenAIResponsesOutputRefusalBlock = typeof outputRefusalSchema.infer; // ─── Tools ────────────────────────────────────────────────────────────────── -export const toolSchema = z.object({ - type: z.literal("function"), - name: z.string().min(1), - description: z.string().optional(), - parameters: z.record(z.string(), z.unknown()).optional(), - strict: z.boolean().optional(), +export const toolSchema = type({ + type: "'function'", + name: "string >= 1", + "description?": "string", + "parameters?": type({ "[string]": "unknown" }), + "strict?": "boolean", }); // Built-in / hosted tool entries (web_search_preview, file_search, …) — accepted // but skipped by the walker. -const builtinToolSchema = z - .object({ - type: z.string(), - }) - .loose(); +const builtinToolSchema = type({ + type: "string", +}); // ─── Tool choice ──────────────────────────────────────────────────────────── -const hostedToolType = z.enum([ - "web_search_preview", - "file_search", - "computer_use_preview", - "code_interpreter", - "image_generation", - "mcp", -]); +const hostedToolType = type( + "'web_search_preview' | 'file_search' | 'computer_use_preview' | 'code_interpreter' | 'image_generation' | 'mcp'", +); -const allowedToolEntrySchema = z.object({ - type: z.string(), - name: z.string().optional(), +const allowedToolEntrySchema = type({ + type: "string", + "name?": "string", }); -export const toolChoiceSchema = z.union([ - z.literal("auto"), - z.literal("none"), - z.literal("required"), - z.object({ - type: z.literal("function"), - name: z.string().min(1), - }), - // Codex apply_patch flow. - z.object({ - type: z.literal("custom"), - name: z.string().min(1), - }), - // Hosted-tool selection (no extra fields). - z.object({ - type: hostedToolType, - }), - // `allowed_tools` — walker treats as auto. - z.object({ - type: z.literal("allowed_tools"), - mode: z.enum(["auto", "required"]), - tools: z.array(allowedToolEntrySchema), - }), -]); +export const toolChoiceSchema = type("'auto' | 'none' | 'required'") + .or( + type({ + type: "'function'", + name: "string >= 1", + }), + ) + .or( + type({ + type: "'custom'", + name: "string >= 1", + }), + ) + .or( + type({ + type: hostedToolType, + }), + ) + .or( + type({ + type: "'allowed_tools'", + mode: "'auto' | 'required'", + tools: allowedToolEntrySchema.array(), + }), + ); // ─── Reasoning config ─────────────────────────────────────────────────────── -export const reasoningConfigSchema = z.object({ - effort: z.string().optional(), +export const reasoningConfigSchema = type({ + "effort?": "string", // `none` maps to hideThinkingSummary; auto/concise/detailed mean "show // summary". pi-ai has no per-level plumbing for the latter — walker logs // once and treats them as default. - summary: z.enum(["auto", "concise", "detailed", "none"]).optional(), + "summary?": "'auto' | 'concise' | 'detailed' | 'none'", }); // ─── Stop ─────────────────────────────────────────────────────────────────── -export const stopSchema = z.union([z.string(), z.array(z.string()), z.null()]); +export const stopSchema = type("string | string[] | null"); // ─── Top-level request ────────────────────────────────────────────────────── -export const openaiResponsesRequestSchema = z.object({ - model: z.string().min(1), - input: z.union([z.string(), z.array(inputItemSchema)]).optional(), - instructions: z.union([z.string(), z.null()]).optional(), - tools: z.array(z.union([toolSchema, builtinToolSchema])).optional(), - tool_choice: toolChoiceSchema.optional(), - max_output_tokens: z.number().optional(), - temperature: z.number().optional(), - top_p: z.number().optional(), - stop: stopSchema.optional(), - stream: z.boolean().optional(), - reasoning: reasoningConfigSchema.optional(), - store: z.boolean().optional(), - previous_response_id: z.string().optional(), - parallel_tool_calls: z.boolean().optional(), - prompt_cache_key: z.string().optional(), - metadata: z.unknown().optional(), - user: z.string().optional(), - service_tier: z.string().optional(), - presence_penalty: z.number().optional(), - frequency_penalty: z.number().optional(), +export const openaiResponsesRequestSchema = type({ + model: "string >= 1", + "input?": type("string").or(inputItemSchema.array()), + "instructions?": "string | null", + "tools?": toolSchema.or(builtinToolSchema).array(), + "tool_choice?": toolChoiceSchema, + "max_output_tokens?": "number", + "temperature?": "number", + "top_p?": "number", + "stop?": stopSchema, + "stream?": "boolean", + "reasoning?": reasoningConfigSchema, + "store?": "boolean", + "previous_response_id?": "string", + "parallel_tool_calls?": "boolean", + "prompt_cache_key?": "string", + "metadata?": "unknown", + "user?": "string", + "service_tier?": "string", + "presence_penalty?": "number", + "frequency_penalty?": "number", // Accepted-but-ignored: include `reasoning.encrypted_content` is the canonical // way to request reasoning replay — silently accept and drop. - background: z.unknown().optional(), - include: z.unknown().optional(), - prompt: z.unknown().optional(), - safety_identifier: z.unknown().optional(), - text: z.unknown().optional(), - top_logprobs: z.unknown().optional(), - truncation: z.unknown().optional(), + "background?": "unknown", + "include?": "unknown", + "prompt?": "unknown", + "safety_identifier?": "unknown", + "text?": "unknown", + "top_logprobs?": "unknown", + "truncation?": "unknown", }); /** diff --git a/packages/ai/src/providers/openai-responses-server.ts b/packages/ai/src/providers/openai-responses-server.ts index dc6f49266..b125c15f1 100644 --- a/packages/ai/src/providers/openai-responses-server.ts +++ b/packages/ai/src/providers/openai-responses-server.ts @@ -10,6 +10,7 @@ */ import { logger } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import { resolvePromptCacheKey } from "../auth-gateway/http"; import type { AuthGatewayStreamControl, AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types"; import type { @@ -263,11 +264,10 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest { // client signals a cache identity outside the body — see the // `resolvePromptCacheKey` call further down. - const parsed = openaiResponsesRequestSchema.safeParse(body); - if (!parsed.success) { - throw new Error(`openai-responses: ${parsed.error.message}`); + const data = openaiResponsesRequestSchema(body); + if (data instanceof type.errors) { + throw new Error(`openai-responses: ${data.summary}`); } - const data = parsed.data; const now = Date.now(); const messages: Message[] = []; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 3c4ce9bbb..a49dc95f7 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -69,8 +69,8 @@ import { type OpenAIStrictToolsScope, type OpenAIStrictToolsState, processResponsesStream, - resolveOpenAIOutputTokenParam, resolveOpenAICompatPolicy, + resolveOpenAIOutputTokenParam, resolveOpenAIRequestSetup, shouldRetryWithoutStrictTools, } from "./openai-shared"; diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index d15d23900..effced628 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -199,7 +199,6 @@ export function resolveOpenAIRequestSetup( headers[model.compat.promptCacheSessionHeader] ??= options.promptCacheSessionId; } - if (options.defaultBaseUrl !== undefined) { baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl); } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 5c352c5a6..9f9200c17 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -73,7 +73,6 @@ function isGoogleVertexAuthenticatedModel(model: Model): boolean { ); } - function createVertexAuthenticatedFetch(options: StreamOptions | undefined): FetchImpl { const baseFetch = options?.fetch ?? fetch; const vertexFetch = async (input: string | URL | Request, init?: RequestInit): Promise => { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 98fcbdf8a..8803d8947 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -20,6 +20,7 @@ import type { } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import type { Api, FetchImpl, KnownApi, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types"; +import type { Type } from "arktype"; import type { ZodType, z } from "zod/v4"; import type { ApiKey } from "./auth-retry"; import type { BedrockOptions } from "./providers/amazon-bedrock"; @@ -56,8 +57,8 @@ export interface ApiOptionsMap { "anthropic-messages": AnthropicOptions; "bedrock-converse-stream": BedrockOptions; "openai-completions": OpenAICompletionsOptions; - openrouter: OpenAIResponsesOptions | OpenAICompletionsOptions; "openai-responses": OpenAIResponsesOptions; + openrouter: OpenAIResponsesOptions | OpenAICompletionsOptions; "openai-codex-responses": OpenAICodexResponsesOptions; "azure-openai-responses": AzureOpenAIResponsesOptions; "google-generative-ai": GoogleOptions; @@ -582,20 +583,26 @@ export interface CursorExecHandlers { /** * Plain JSON Schema document used by extension-authored tools (legacy TypeBox - * emits this shape). Distinguished from Zod at runtime via {@link isZodSchema}. + * emits this shape). Distinguished from arktype at runtime. */ export type TJsonSchema = Record; /** * Schema type accepted by the {@link Tool} interface. * - * Canonical authoring uses Zod. Extension compat may supply a JSON Schema - * object (including TypeBox static schema objects). + * Canonical authoring uses Zod or ArkType. Extension compat may supply a JSON + * Schema object (including TypeBox static schema objects). */ -export type TSchema = ZodType | TJsonSchema; +export type TSchema = ZodType | Type | TJsonSchema; /** Resolve parameter types for tool execution / handlers. */ -export type Static = S extends ZodType ? z.infer : S extends { static: infer T } ? T : unknown; +export type Static = S extends ZodType + ? z.infer + : S extends Type + ? S["infer"] + : S extends { static: infer T } + ? T + : unknown; export interface ToolCallExample> { caption?: string; @@ -644,7 +651,7 @@ export interface Tool { * description. Author `call`/`bad`/`good` as plain argument objects WITHOUT * `_i` — when intent tracing injects `_i` into the schema, the renderer adds * a placeholder `_i` automatically. Type each tool's `examples` against its - * own schema (e.g. `readonly ToolExample>[]`). + * own schema (e.g. `readonly ToolExample[]`). */ examples?: readonly ToolExample[]; } diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 7afe58abf..243ee0021 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -4,7 +4,7 @@ * Provides a normalized schema to represent multiple limit windows, model tiers, * and shared quotas across providers. */ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { FetchImpl, Provider } from "./types"; export type UsageUnit = "percent" | "tokens" | "requests" | "usd" | "minutes" | "bytes" | "unknown"; @@ -137,59 +137,59 @@ export interface UsageHistoryQuery { // ─── Zod schemas (wire-shape validation for the broker `/v1/usage` endpoint) ─ -export const usageUnitSchema = z.enum(["percent", "tokens", "requests", "usd", "minutes", "bytes", "unknown"]); -export const usageStatusSchema = z.enum(["ok", "warning", "exhausted", "unknown"]); +export const usageUnitSchema = type("'percent' | 'tokens' | 'requests' | 'usd' | 'minutes' | 'bytes' | 'unknown'"); +export const usageStatusSchema = type("'ok' | 'warning' | 'exhausted' | 'unknown'"); -export const usageWindowSchema = z.object({ - id: z.string(), - label: z.string(), - durationMs: z.number().optional(), - resetsAt: z.number().optional(), +export const usageWindowSchema = type({ + id: "string", + label: "string", + "durationMs?": "number", + "resetsAt?": "number", }); -export const usageAmountSchema = z.object({ - used: z.number().optional(), - limit: z.number().optional(), - remaining: z.number().optional(), - usedFraction: z.number().optional(), - remainingFraction: z.number().optional(), +export const usageAmountSchema = type({ + "used?": "number", + "limit?": "number", + "remaining?": "number", + "usedFraction?": "number", + "remainingFraction?": "number", unit: usageUnitSchema, }); -export const usageScopeSchema = z.object({ - provider: z.string(), - accountId: z.string().optional(), - projectId: z.string().optional(), - orgId: z.string().optional(), - modelId: z.string().optional(), - tier: z.string().optional(), - windowId: z.string().optional(), - shared: z.boolean().optional(), +export const usageScopeSchema = type({ + provider: "string", + "accountId?": "string", + "projectId?": "string", + "orgId?": "string", + "modelId?": "string", + "tier?": "string", + "windowId?": "string", + "shared?": "boolean", }); -export const usageLimitSchema = z.object({ - id: z.string(), - label: z.string(), +export const usageLimitSchema = type({ + id: "string", + label: "string", scope: usageScopeSchema, - window: usageWindowSchema.optional(), + "window?": usageWindowSchema, amount: usageAmountSchema, - status: usageStatusSchema.optional(), - notes: z.array(z.string()).optional(), + "status?": usageStatusSchema, + "notes?": "string[]", }); -export const usageResetCreditsSchema = z.object({ - availableCount: z.number(), +export const usageResetCreditsSchema = type({ + availableCount: "number", }); -export const usageReportSchema = z.object({ - provider: z.string(), - fetchedAt: z.number(), - limits: z.array(usageLimitSchema), - resetCredits: usageResetCreditsSchema.optional(), - metadata: z.record(z.string(), z.unknown()).optional(), +export const usageReportSchema = type({ + provider: "string", + fetchedAt: "number", + limits: usageLimitSchema.array(), + "resetCredits?": usageResetCreditsSchema, + "metadata?": { "[string]": "unknown" }, // `raw` is provider-specific and may be anything; the broker strips it before // sending the report over the wire, so accept-but-ignore here. - raw: z.unknown().optional(), + "raw?": "unknown", }); /** Optional logger for usage fetchers. */ diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index 9c618afd3..85ee3d3d7 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -1,16 +1,17 @@ /** - * Compute the wire (JSON Schema) representation of a tool's parameters and - * convert TypeBox-style schemas into Zod for internal validation. + * Compute the wire (JSON Schema) representation of a tool's parameters. * - * Tools may author parameters in two shapes: - * 1. Zod (canonical going forward) — converted to JSON Schema on demand. - * 2. TypeBox / plain JSON Schema (legacy + extension compat) — upgraded to - * draft 2020-12 without converting through Zod. + * Tools may author parameters in three shapes: + * 1. Zod (canonical) — converted to JSON Schema on demand. + * 2. ArkType — converted to JSON Schema via its native `toJsonSchema`. + * 3. TypeBox / plain JSON Schema (legacy + extension compat) — upgraded to + * draft 2020-12 without converting. * - * Both are normalized at the boundary so providers and validators see the same + * All three are normalized at the boundary so providers and validators see the same * JSON Schema dialect. */ +import type { Type } from "arktype"; // We import the Zod *value* (z) for runtime APIs. Marker checks rely on the // `_zod` symbol that every Zod v4 schema instance carries. import { type ZodType, z } from "zod/v4"; @@ -49,9 +50,26 @@ export function isZodSchema(value: unknown): value is ZodType { ); } +/** + * True when `value` is a live ArkType schema instance. + * + * ArkType schemas are callable functions carrying `toJsonSchema`/`assert` + * methods. Zod v4 instances are non-callable objects (keyed off `_zod`), and + * raw JSON Schema is a plain object — the three are disjoint. We deliberately + * avoid the Standard Schema `~standard` marker because Zod v4 implements it too. + */ +export function isArkSchema(value: unknown): value is Type { + return ( + typeof value === "function" && + typeof (value as { toJsonSchema?: unknown }).toJsonSchema === "function" && + typeof (value as { assert?: unknown }).assert === "function" + ); +} + /** Symbol-stamped caches keyed by schema object identity. */ const kZodWireSchema = Symbol("pi.schema.zod.wire"); const kJsonWireSchema = Symbol("pi.schema.json.wire"); +const kArkWireSchema = Symbol("pi.schema.ark.wire"); /** * Post-process Zod-emitted JSON Schema so it matches the wire shape providers @@ -290,6 +308,70 @@ export function zodToWireSchema(schema: ZodType): Record { }); } +/** + * Recursively set `additionalProperties: false` on declared object nodes so the + * model-facing wire matches Zod's closed emission. Only nodes that declare + * `properties` and carry neither `additionalProperties` nor `patternProperties` + * are closed — open record/index nodes (which already carry one of those, e.g. + * `additionalProperties: true` after empty-schema normalization) stay open. + * + * Traverses only schema-valued positions via the shared traversal-key constants + * so it never descends into `default`/`examples`/`enum`/`const` instance data. + */ +function closeDeclaredObjects(node: unknown): void { + if (Array.isArray(node)) { + for (const child of node) closeDeclaredObjects(child); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + + const isObjectType = obj.type === "object" || (Array.isArray(obj.type) && obj.type.includes("object")); + if ( + isObjectType && + obj.properties !== undefined && + !("additionalProperties" in obj) && + !("patternProperties" in obj) + ) { + obj.additionalProperties = false; + } + + for (const key of SCHEMA_VALUE_KEYS) { + if (Object.hasOwn(obj, key)) closeDeclaredObjects(obj[key]); + } + for (const mapKey of SCHEMA_MAP_KEYS) { + const map = obj[mapKey]; + if (map !== null && typeof map === "object" && !Array.isArray(map)) { + for (const k in map as Record) closeDeclaredObjects((map as Record)[k]); + } + } + for (const arrKey of SCHEMA_ARRAY_KEYS) { + const arr = obj[arrKey]; + if (Array.isArray(arr)) for (const child of arr) closeDeclaredObjects(child); + } +} + +/** + * Convert an ArkType schema into the JSON Schema shape providers consume. + * + * Mirrors {@link zodToWireSchema}: emit draft-2020-12, drop the `$schema` + * metadata, run the JSON-schema post-process (NOT the Zod-only cleanup), then + * close declared objects so the wire is `additionalProperties: false` like Zod. + * + * The `fallback` degrades any un-emittable node (a `.narrow()` predicate or a + * morph) to its underlying base schema instead of throwing — matching Zod, + * whose `.refine()`/`.transform()` likewise never appear in the wire schema. + */ +export function arkToWireSchema(schema: Type): Record { + return stamp(schema, kArkWireSchema, s => { + const raw = s.toJsonSchema({ target: "draft-2020-12", fallback: ctx => ctx.base }) as Record; + delete raw.$schema; + const upgraded = postProcessJsonSchema(upgradeJsonSchemaTo202012(raw) as Record); + closeDeclaredObjects(upgraded); + return upgraded; + }); +} + /** * Resolve a tool's parameters to a JSON Schema object suitable for sending * over the wire. Zod schemas are converted (and cached); legacy TypeBox / raw @@ -300,6 +382,7 @@ export function zodToWireSchema(schema: ZodType): Record { */ export function toolWireSchema(tool: Tool): Record { const params: TSchema = tool.parameters; + if (isArkSchema(params)) return arkToWireSchema(params); if (isZodSchema(params)) return zodToWireSchema(params); return stamp(params as Record, kJsonWireSchema, p => { const upgraded = upgradeJsonSchemaTo202012(p) as Record; diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 007be3f0b..382941bb0 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -23,6 +23,7 @@ * massage shapes the LLM almost got right. */ import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; +import { type Type, type } from "arktype"; import type { ZodType } from "zod/v4"; import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import type { Tool, ToolCall } from "../types"; @@ -32,7 +33,8 @@ import { type JsonSchemaValidationIssue, validateJsonSchemaValue, } from "./schema/json-schema-validator"; -import { isZodSchema, zodToWireSchema } from "./schema/wire"; +import { stamp } from "./schema/stamps"; +import { arkToWireSchema, isArkSchema, isZodSchema, zodToWireSchema } from "./schema/wire"; // ============================================================================ // Type Coercion Utilities @@ -1138,6 +1140,11 @@ type ValidationContext = zod: ZodType; json: Record; } + | { + kind: "arktype"; + ark: Type; + json: Record; + } | { kind: "json"; json: Record; @@ -1145,20 +1152,19 @@ type ValidationContext = /** * Cache the validation context derived from a tool's parameters schema. - * Keyed by the parameters object identity, which is stable across tool - * registrations. + * Keyed by the parameters object identity (stable across tool registrations), + * via {@link stamp} so callable ArkType schemas — and any frozen host — degrade + * to recompute-on-call instead of throwing on assignment. */ const kValidationContext = Symbol("ai.validationContext"); -type ParamsWithValidationContext = object & { [kValidationContext]?: ValidationContext }; function getValidationContext(tool: Tool): ValidationContext { - const params = tool.parameters as ParamsWithValidationContext; - const existing = params[kValidationContext]; - if (existing) return existing; - const ctx: ValidationContext = isZodSchema(params) - ? { kind: "zod", zod: params, json: zodToWireSchema(params) } - : { kind: "json", json: upgradeJsonSchemaTo202012(params) as Record }; - params[kValidationContext] = ctx; - return ctx; + return stamp(tool.parameters as object, kValidationContext, params => + isArkSchema(params) + ? { kind: "arktype", ark: params, json: arkToWireSchema(params) } + : isZodSchema(params) + ? { kind: "zod", zod: params, json: zodToWireSchema(params) } + : { kind: "json", json: upgradeJsonSchemaTo202012(params) as Record }, + ); } type ContextValidationResult = @@ -1211,6 +1217,24 @@ function validateContext(ctx: ValidationContext, value: unknown): ContextValidat }; } + if (ctx.kind === "arktype") { + const out = ctx.ark(value); + if (!(out instanceof type.errors)) { + return { success: true, value: preserveUnknownRootFields(value, out) }; + } + // A `.narrow()`/cross-field failure can have ArkType reject while the wire + // JSON (its predicate dropped by the toJsonSchema fallback) accepts — then + // there are no json issues to coerce and we fall through to the formatted + // error built from ArkType's own messages. + const jr = validateJsonSchemaValue(ctx.json, value); + const flatIssues = jr.success ? [] : flattenJsonSchemaIssues(jr.issues); + return { + success: false, + flatIssues, + messages: out.map(e => ` - ${formatIssuePath(e.path)}: ${e.message}`), + }; + } + const result = validateJsonSchemaValue(ctx.json, value); if (result.success) return { success: true, value }; return { diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index acd6b0645..47a966f68 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -32,7 +32,7 @@ import type { Tool, } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { z } from "zod/v4"; +import { type as arkType } from "arktype"; import { withEnv } from "./helpers"; const ANTHROPIC_MODEL_SPEC: ModelSpec<"anthropic-messages"> = { @@ -1217,9 +1217,9 @@ describe("Anthropic request fingerprint alignment", () => { name: "bash", description: "bash tool", strict: true, - parameters: z.object({ - command: z.string(), - cwd: z.string(), + parameters: arkType({ + command: "string", + cwd: "string", }), }, ]; diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index 045a95f94..a0ac5afdf 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -16,7 +16,7 @@ import { } from "@oh-my-pi/pi-ai/providers/openai-shared"; import type { AssistantMessage, Model, ModelSpec, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { z } from "zod/v4"; +import { type } from "arktype"; const GRAMMAR = [ "// top-level comment", @@ -64,14 +64,14 @@ const editTool: Tool = { name: "edit", customWireName: "apply_patch", description: "edit files", - parameters: z.object({ input: z.string() }), + parameters: type({ input: "string" }), customFormat: { syntax: "lark", definition: GRAMMAR }, }; const plainTool: Tool = { name: "read_file", description: "read a file", - parameters: z.object({ path: z.string() }), + parameters: type({ path: "string" }), }; const unionBranches = [ @@ -513,13 +513,13 @@ describe("dispatcher wire-name matching", () => { name: "edit", customWireName: "apply_patch", description: "edit files", - parameters: z.object({ input: z.string() }), + parameters: type({ input: "string" }), customFormat: { syntax: "lark", definition: GRAMMAR }, }; const readTool: Tool = { name: "read_file", description: "read", - parameters: z.object({ path: z.string() }), + parameters: type({ path: "string" }), }; const tools = [editLikeTool, readTool]; const toolCall = { name: "apply_patch" }; @@ -540,13 +540,13 @@ describe("dispatcher wire-name matching", () => { const nameMatch: Tool = { name: "foo", description: "", - parameters: z.object({}), + parameters: type({}), }; const wireMatch: Tool & { customWireName: string } = { name: "bar", customWireName: "foo", description: "", - parameters: z.object({}), + parameters: type({}), }; const tools = [wireMatch, nameMatch]; // wireMatch listed first const toolCall = { name: "foo" }; diff --git a/packages/ai/test/issue-1207-repro.test.ts b/packages/ai/test/issue-1207-repro.test.ts index df9a637cb..32dfe2a89 100644 --- a/packages/ai/test/issue-1207-repro.test.ts +++ b/packages/ai/test/issue-1207-repro.test.ts @@ -3,12 +3,12 @@ import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-comple import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; const echoTool: Tool = { name: "echo", description: "Echo input", - parameters: z.object({ text: z.string() }), + parameters: type({ text: "string" }), }; function contextWithTools(tools: Tool[] = [echoTool]): Context { @@ -123,8 +123,8 @@ describe("issue #1207 — DeepSeek V4 keeps reasoning with tools", () => { const unionTool: Tool = { name: "union_repro", description: "Union schema repro", - parameters: z.object({ - paths: z.union([z.string(), z.array(z.string())]).optional(), + parameters: type({ + paths: "(string | string[])?", }), }; const body = await capturePayload(model, [unionTool]); diff --git a/packages/ai/test/issue-1227-repro.test.ts b/packages/ai/test/issue-1227-repro.test.ts index eccbef718..4b6a46a26 100644 --- a/packages/ai/test/issue-1227-repro.test.ts +++ b/packages/ai/test/issue-1227-repro.test.ts @@ -20,7 +20,7 @@ import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-comple import type { AssistantMessage, Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; function abortedSignal(): AbortSignal { const controller = new AbortController(); @@ -78,7 +78,7 @@ function assistantWithToolCall(): AssistantMessage { const echoTool: Tool = { name: "echo", description: "Echo input", - parameters: z.object({ text: z.string() }), + parameters: type({ text: "string" }), }; describe("issue #1227 — /btw fails on LiteLLM→Bedrock with tool history", () => { diff --git a/packages/ai/test/issue-827-repro.test.ts b/packages/ai/test/issue-827-repro.test.ts index 111ecfd8c..d6641e895 100644 --- a/packages/ai/test/issue-827-repro.test.ts +++ b/packages/ai/test/issue-827-repro.test.ts @@ -12,12 +12,12 @@ import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-comple import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; const echoTool: Tool = { name: "echo", description: "Echo input", - parameters: z.object({ text: z.string() }), + parameters: type({ text: "string" }), }; const ctx: Context = { @@ -111,7 +111,7 @@ describe("issue #827 — kimi reasoning models drop reasoning under forced tool_ })) as CompletionsBody; expect(body.tool_choice).toMatchObject({ type: "function", function: { name: "echo" } }); - expect(body.reasoning).toEqual({ enabled: false }); + expect(body.reasoning).toBeUndefined(); expect(body.reasoning_effort).toBeUndefined(); }); it("sends explicit thinking disabled for Moonshot Kimi K2.6 when a named tool is forced", async () => { diff --git a/packages/ai/test/issue-945-repro.test.ts b/packages/ai/test/issue-945-repro.test.ts index 3154b5f2c..e94270e74 100644 --- a/packages/ai/test/issue-945-repro.test.ts +++ b/packages/ai/test/issue-945-repro.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "bun:test"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; const echoTool: Tool = { name: "echo", description: "Echo input", - parameters: z.object({ text: z.string() }), + parameters: type({ text: "string" }), }; const context: Context = { diff --git a/packages/ai/test/openai-compat-policy.test.ts b/packages/ai/test/openai-compat-policy.test.ts index 0a58a6f6f..33034ce86 100644 --- a/packages/ai/test/openai-compat-policy.test.ts +++ b/packages/ai/test/openai-compat-policy.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test"; +import type { ResponseCreateParamsStreaming, ResponseInput } from "@oh-my-pi/pi-ai/providers/openai-responses-wire"; import { applyChatCompletionsCompatPolicy, applyResponsesCompatPolicy, - resolveOpenAICompatPolicy, type OpenAICompletionsParams, + resolveOpenAICompatPolicy, } from "@oh-my-pi/pi-ai/providers/openai-shared"; -import type { ResponseCreateParamsStreaming, ResponseInput } from "@oh-my-pi/pi-ai/providers/openai-responses-wire"; import type { Model, ModelSpec, OpenAICompat } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; diff --git a/packages/ai/test/openai-responses-tool-quarantine.test.ts b/packages/ai/test/openai-responses-tool-quarantine.test.ts index b6ff1ac6b..bf7e4ad8a 100644 --- a/packages/ai/test/openai-responses-tool-quarantine.test.ts +++ b/packages/ai/test/openai-responses-tool-quarantine.test.ts @@ -3,7 +3,7 @@ import { buildParams, convertTools } from "@oh-my-pi/pi-ai/providers/openai-resp import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { findStrictToolSchemaViolation } from "@oh-my-pi/pi-ai/utils/schema"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { z } from "zod/v4"; +import { type } from "arktype"; function makeModel(): Model<"openai-responses"> { return buildModel({ @@ -69,7 +69,7 @@ const badTool: Tool = { const goodTool: Tool = { name: "read_file", description: "read a file", - parameters: z.object({ path: z.string() }), + parameters: type({ path: "string" }), }; describe("convertTools quarantine (#2652)", () => { diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index e47478f70..a45e29a49 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -12,13 +12,13 @@ import type { } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; const testTool: Tool = { name: "echo", description: "Echo input", - parameters: z.object({ - text: z.string(), + parameters: type({ + text: "string", }), }; @@ -196,8 +196,8 @@ describe("OpenAI tool strict mode", () => { { name: "dynamic_map", description: "Dynamic object map", - parameters: z.object({ - values: z.record(z.string(), z.string()).optional(), + parameters: type({ + values: "Record?", }), }, ], diff --git a/packages/ai/test/schema-arktype.test.ts b/packages/ai/test/schema-arktype.test.ts new file mode 100644 index 000000000..3e084276b --- /dev/null +++ b/packages/ai/test/schema-arktype.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "bun:test"; +import type { Tool } from "@oh-my-pi/pi-ai/types"; +import { isArkSchema, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; +import { type } from "arktype"; +import { z } from "zod/v4"; + +// --------------------------------------------------------------------------- +// Phase-1 parity gate: ArkType schemas must flow through the same wire-emission +// + validation/coercion pipeline as Zod, with the two contracts the dispatcher +// depends on (root-extra survival on plain objects; strip-extras on closed +// objects) preserved. +// --------------------------------------------------------------------------- + +// Plain object — ArkType's default `ignore` undeclared-key behavior. +const plainParams = type({ + name: type("string").describe("the display name"), + count: type("number").describe("how many items"), + nested: type({ inner: type("string").describe("inner value") }).describe("a nested object"), +}); +const plainTool: Tool = { name: "ark-plain", description: "", parameters: plainParams }; + +// Closed object — the `.strict()` analogue (`"+": "reject"`), applied to root +// and the nested object. +const rejectParams = type({ + "+": "reject", + name: "string", + count: "number", + obj: type({ "+": "reject", inner: "string" }), +}); +const rejectTool: Tool = { name: "ark-reject", description: "", parameters: rejectParams }; + +describe("isArkSchema", () => { + it("accepts a live ArkType instance", () => { + expect(isArkSchema(type({ a: "string" }))).toBe(true); + expect(isArkSchema(type("string"))).toBe(true); + }); + + it("rejects Zod schemas, JSON Schema objects, and non-objects", () => { + expect(isArkSchema(z.object({ a: z.string() }))).toBe(false); + expect(isArkSchema({ type: "object", properties: {} })).toBe(false); + expect(isArkSchema(null)).toBe(false); + expect(isArkSchema(undefined)).toBe(false); + expect(isArkSchema("string")).toBe(false); + expect(isArkSchema(42)).toBe(false); + }); +}); + +describe("arkToWireSchema — emission", () => { + it("emits closed declared objects with per-field descriptions and required", () => { + const wire = toolWireSchema(plainTool); + expect(wire.type).toBe("object"); + // Declared objects are closed to match Zod's emission. + expect(wire.additionalProperties).toBe(false); + expect([...(wire.required as string[])].sort()).toEqual(["count", "name", "nested"]); + + const props = wire.properties as Record>; + expect(props.name).toMatchObject({ type: "string", description: "the display name" }); + expect(props.count).toMatchObject({ type: "number", description: "how many items" }); + + const nested = props.nested as Record; + expect(nested.type).toBe("object"); + expect(nested.description).toBe("a nested object"); + expect(nested.additionalProperties).toBe(false); + const nestedProps = nested.properties as Record>; + expect(nestedProps.inner).toMatchObject({ type: "string", description: "inner value" }); + }); + + it("never emits the $schema metadata key", () => { + expect(toolWireSchema(plainTool).$schema).toBeUndefined(); + expect(toolWireSchema(rejectTool).$schema).toBeUndefined(); + }); + + it("emits a wire structurally equal to the plain-Zod equivalent", () => { + const zodTwin: Tool = { + name: "zod-plain", + description: "", + parameters: z.object({ + name: z.string().describe("the display name"), + count: z.number().describe("how many items"), + nested: z.object({ inner: z.string().describe("inner value") }).describe("a nested object"), + }), + }; + // Normalize key + array ordering (semantically irrelevant to JSON Schema). + const norm = (x: unknown): unknown => + Array.isArray(x) + ? [...x].map(norm).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + : x && typeof x === "object" + ? Object.fromEntries( + Object.keys(x as Record) + .sort() + .map(k => [k, norm((x as Record)[k])]), + ) + : x; + expect(norm(toolWireSchema(plainTool))).toEqual(norm(toolWireSchema(zodTwin))); + }); +}); + +describe("validateToolArguments — ArkType contracts", () => { + it("Contract A: plain type preserves the unknown root key (and, by divergence, nested extras)", () => { + const result = validateToolArguments(plainTool, { + type: "toolCall", + id: "a", + name: "ark-plain", + arguments: { name: "n", count: 2, nested: { inner: "v", extraNested: 9 }, extraRoot: 7 }, + }) as Record; + expect(result.name).toBe("n"); + // Root extra survives — load-bearing (preserveUnknownRootFields), matches plain z.object. + expect(result.extraRoot).toBe(7); + // Accepted divergence from plain z.object: nested extras also survive. + expect((result.nested as Record).extraNested).toBe(9); + }); + + it('Contract B: "+": "reject" strips both root and nested extras', () => { + const result = validateToolArguments(rejectTool, { + type: "toolCall", + id: "b", + name: "ark-reject", + arguments: { name: "n", count: 2, obj: { inner: "v", extraNested: 9 }, extraRoot: 7 }, + }) as Record; + expect(result.name).toBe("n"); + expect(result.extraRoot).toBeUndefined(); + expect((result.obj as Record).extraNested).toBeUndefined(); + }); + + it("coerces a numeric string for a numeric field", () => { + const result = validateToolArguments(plainTool, { + type: "toolCall", + id: "c", + name: "ark-plain", + arguments: { name: "n", count: "300", nested: { inner: "v" } }, + }) as Record; + expect(result.count).toBe(300); + expect(typeof result.count).toBe("number"); + }); + + it("throws the standard header on a missing required field", () => { + expect(() => + validateToolArguments(plainTool, { + type: "toolCall", + id: "d", + name: "ark-plain", + arguments: { count: 2, nested: { inner: "v" } }, + }), + ).toThrow(/Validation failed for tool "ark-plain"/); + }); + + it("matches plain Zod exactly — root extras survive even through a coercion pass", () => { + // The numeric-string forces a coercion pass. The plan predicted the closed + // wire would strip the root extra here ("divergence 2"), but it does NOT: + // root extras are preserved identically to plain z.object, proving full + // wire + coercion parity between the two authoring styles. + const args = { name: "n", count: "300", nested: { inner: "v" }, extraRoot: 1 }; + const arkResult = validateToolArguments(plainTool, { + type: "toolCall", + id: "e", + name: "ark-plain", + arguments: structuredClone(args), + }); + const zodTwin: Tool = { + name: "zod-plain", + description: "", + parameters: z.object({ + name: z.string(), + count: z.number(), + nested: z.object({ inner: z.string() }), + }), + }; + const zodResult = validateToolArguments(zodTwin, { + type: "toolCall", + id: "e2", + name: "zod-plain", + arguments: structuredClone(args), + }); + expect(arkResult).toEqual(zodResult); + expect((arkResult as Record).count).toBe(300); + expect((arkResult as Record).extraRoot).toBe(1); + }); +}); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 4f38f69da..23bc84cd7 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -10,7 +10,7 @@ import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResult import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) @@ -36,12 +36,10 @@ function hasBedrockCredentials(): boolean { } // Calculator tool definition (same as examples) -const calculatorSchema = z.object({ - a: z.number().describe("First number"), - b: z.number().describe("Second number"), - operation: z - .enum(["add", "subtract", "multiply", "divide"]) - .describe("The operation to perform. One of 'add', 'subtract', 'multiply', 'divide'."), +const calculatorSchema = type({ + a: "number", + b: "number", + operation: "'add'|'subtract'|'multiply'|'divide'", }); const calculatorTool: Tool = { diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index ac42ffb27..ebe74b0b3 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Tool } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) @@ -16,8 +16,8 @@ const oauthTokens = await Promise.all([ const [anthropicOAuthToken, githubCopilotToken, geminiCliToken, antigravityToken, openaiCodexToken] = oauthTokens; // Simple calculate tool -const calculateSchema = z.object({ - expression: z.string().describe("The mathematical expression to evaluate"), +const calculateSchema = type({ + expression: "string", }); const calculateTool: Tool = { diff --git a/packages/ai/test/tool-inventory.test.ts b/packages/ai/test/tool-inventory.test.ts index 2bad21832..9db4a98ad 100644 --- a/packages/ai/test/tool-inventory.test.ts +++ b/packages/ai/test/tool-inventory.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "bun:test"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { renderToolInventory } from "../src/dialect/inventory"; import type { InbandTool } from "../src/dialect/types"; const searchTool: InbandTool = { name: "web_search", description: "Searches the web.", - parameters: z.object({ - query: z.string().describe("search query"), - recency: z.enum(["day", "week"]).optional(), + parameters: type({ + query: type("string").describe("search query"), + "recency?": type("'day' | 'week'"), }), examples: [{ caption: "Basic", call: { query: "rust" } }], }; @@ -30,7 +30,7 @@ describe("renderToolInventory", () => { const tool: InbandTool = { name: "noop", description: "No examples.", - parameters: z.object({ x: z.string() }), + parameters: type({ x: type("string") }), }; const out = renderToolInventory([tool], "claude-3-5-sonnet-20241022"); expect(out).toContain("Parameters: {"); @@ -45,7 +45,7 @@ describe("renderToolInventory", () => { const tool: InbandTool = { name: "read", description: ["Reads files.", "", "## Parameters", "", "- `path`", "", "# Files", "", "Stuff."].join("\n"), - parameters: z.object({ path: z.string() }), + parameters: type({ path: type("string") }), }; const out = renderToolInventory([tool], "claude-3-5-sonnet-20241022"); // The wrapper heading stays at level 1; description headers drop one level. @@ -60,7 +60,7 @@ describe("renderToolInventory", () => { const tool: InbandTool = { name: "noop", description: ["Does nothing.", "", "## Parameters", "", "- `x`"].join("\n"), - parameters: z.object({ x: z.string() }), + parameters: type({ x: type("string") }), }; const out = renderToolInventory([tool], "claude-3-5-sonnet-20241022"); expect(out).toContain("\n## Parameters"); @@ -71,7 +71,7 @@ describe("renderToolInventory", () => { const tool: InbandTool = { name: "shell", description: ["Runs commands.", "", "# Usage", "", "```bash", "# not a header", "ls", "```"].join("\n"), - parameters: z.object({ cmd: z.string() }), + parameters: type({ cmd: type("string") }), }; const out = renderToolInventory([tool], "claude-3-5-sonnet-20241022"); expect(out).toContain("\n## Usage"); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index fd0874a28..e3d187fb2 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "bun:test"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist -const emptySchema = z.object({}); +const emptySchema = type({}); // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/catalog/src/compat/openai.ts b/packages/catalog/src/compat/openai.ts index 58359a3e0..004243db9 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -268,7 +268,6 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv ? "qwen" : "openai"; - const compat: ResolvedOpenAICompat = { supportsStore: !isNonStandard, // `developer` is an OpenAI-Responses-era extension to the chat-completions schema. Almost @@ -373,7 +372,6 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv compat.omitReasoningEffort = true; } - const whenThinkingPolicy = spec.compat?.whenThinking ?? (isOpenCodeProvider && spec.reasoning ? OPENCODE_WHEN_THINKING : undefined); if (whenThinkingPolicy) { diff --git a/packages/catalog/src/discovery/antigravity.ts b/packages/catalog/src/discovery/antigravity.ts index b26c80bfc..515eaa802 100644 --- a/packages/catalog/src/discovery/antigravity.ts +++ b/packages/catalog/src/discovery/antigravity.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ModelSpec } from "../types"; import { toPositiveNumber } from "../utils"; import { @@ -56,94 +56,78 @@ export interface AntigravityDiscoveryApiResponse { models?: Record; agentModelSorts?: AntigravityDiscoveryAgentModelSort[]; } -const AntigravityDiscoveryApiModelSchema: z.ZodType = z - .object({ - displayName: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), - supportsImages: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), - supportsThinking: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), - thinkingBudget: z.preprocess( - value => (typeof value === "number" && Number.isFinite(value) ? value : undefined), - z.number().optional(), - ), - recommended: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), - maxTokens: z.preprocess( - value => (typeof value === "number" && Number.isFinite(value) ? value : undefined), - z.number().optional(), - ), - maxOutputTokens: z.preprocess( - value => (typeof value === "number" && Number.isFinite(value) ? value : undefined), - z.number().optional(), - ), - model: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), - apiProvider: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), - modelProvider: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), - isInternal: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), - supportsVideo: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), - }) - .loose(); -const AntigravityDiscoveryAgentModelGroupSchema: z.ZodType = z - .object({ - modelIds: z.preprocess( - value => - Array.isArray(value) - ? value.filter((modelId): modelId is string => typeof modelId === "string") - : undefined, - z.array(z.string()).optional(), - ), - }) - .loose(); -const AntigravityDiscoveryAgentModelSortSchema: z.ZodType = z - .object({ - groups: z.preprocess( - value => (Array.isArray(value) ? value : undefined), - z - .array(z.unknown()) - .transform(groups => - groups.flatMap(group => { - const parsedGroup = AntigravityDiscoveryAgentModelGroupSchema.safeParse(group); - return parsedGroup.success ? [parsedGroup.data] : []; - }), - ) - .optional(), - ), - }) - .loose(); -const AntigravityDiscoveryApiResponseSchema: z.ZodType = z - .object({ - models: z.preprocess( - value => (typeof value === "object" && value !== null ? value : undefined), - z - .record(z.string(), z.unknown()) - .transform(models => { - const normalized: Record = {}; - for (const [modelId, modelValue] of Object.entries(models)) { - if (typeof modelValue !== "object" || modelValue === null) { - continue; - } - const parsedModel = AntigravityDiscoveryApiModelSchema.safeParse(modelValue); - if (parsedModel.success) { - normalized[modelId] = parsedModel.data; - } - } - return normalized; - }) - .optional(), - ), - agentModelSorts: z.preprocess( - value => (Array.isArray(value) ? value : undefined), - z - .array(z.unknown()) - .transform(sorts => - sorts.flatMap(sort => { - const parsedSort = AntigravityDiscoveryAgentModelSortSchema.safeParse(sort); - return parsedSort.success ? [parsedSort.data] : []; - }), - ) - .optional(), - ), - }) - .loose(); +const AntigravityDiscoveryApiModelSchema = type({ + "displayName?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)), + "supportsImages?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)), + "supportsThinking?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)), + "thinkingBudget?": type("unknown").pipe(value => + typeof value === "number" && Number.isFinite(value) ? value : undefined, + ), + "recommended?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)), + "maxTokens?": type("unknown").pipe(value => + typeof value === "number" && Number.isFinite(value) ? value : undefined, + ), + "maxOutputTokens?": type("unknown").pipe(value => + typeof value === "number" && Number.isFinite(value) ? value : undefined, + ), + "model?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)), + "apiProvider?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)), + "modelProvider?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)), + "isInternal?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)), + "supportsVideo?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)), +}); +const AntigravityDiscoveryAgentModelGroupSchema = type({ + "modelIds?": type("unknown").pipe(value => + Array.isArray(value) ? value.filter((modelId): modelId is string => typeof modelId === "string") : undefined, + ), +}); + +const AntigravityDiscoveryAgentModelSortSchema = type({ + "groups?": type("unknown").pipe(value => { + if (!Array.isArray(value)) return undefined; + const result: AntigravityDiscoveryAgentModelGroup[] = []; + for (const group of value) { + const parsedGroup = AntigravityDiscoveryAgentModelGroupSchema(group); + if (!(parsedGroup instanceof type.errors)) { + result.push(parsedGroup); + } + } + return result; + }), +}); + +const AntigravityDiscoveryApiResponseSchema = type({ + "models?": type("unknown").pipe(value => { + if (typeof value !== "object" || value === null) { + return undefined; + } + const normalized: Record = {}; + for (const [modelId, modelValue] of Object.entries(value)) { + if (typeof modelValue !== "object" || modelValue === null) { + continue; + } + const parsedModel = AntigravityDiscoveryApiModelSchema(modelValue); + if (!(parsedModel instanceof type.errors)) { + normalized[modelId] = parsedModel; + } + } + return normalized; + }), + "agentModelSorts?": type("unknown").pipe(value => { + if (!Array.isArray(value)) { + return undefined; + } + const result: AntigravityDiscoveryAgentModelSort[] = []; + for (const sort of value) { + const parsedSort = AntigravityDiscoveryAgentModelSortSchema(sort); + if (!(parsedSort instanceof type.errors)) { + result.push(parsedSort); + } + } + return result; + }), +}); /** * Options for fetching Antigravity discovery models. */ @@ -257,11 +241,11 @@ export async function fetchAntigravityDiscoveryModels( } function parseAntigravityDiscoveryResponse(value: unknown): AntigravityDiscoveryApiResponse | null { - const parsed = AntigravityDiscoveryApiResponseSchema.safeParse(value); - if (!parsed.success) { + const parsed = AntigravityDiscoveryApiResponseSchema(value); + if (parsed instanceof type.errors) { return null; } - return parsed.data; + return parsed; } function trimTrailingSlashes(value: string): string { diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index be1df4d8b..5d7d81299 100644 --- a/packages/catalog/src/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ModelSpec } from "../types"; import { isRecord } from "../utils"; import { CODEX_BASE_URL, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../wire/codex"; @@ -9,36 +9,29 @@ const DEFAULT_MAX_TOKENS = 128_000; const DEFAULT_CODEX_CLIENT_VERSION = "0.99.0"; const NPM_CODEX_LATEST_URL = "https://registry.npmjs.org/@openai%2Fcodex/latest"; -const codexReasoningPresetSchema = z - .object({ - effort: z.unknown().optional(), - }) - .loose(); +const codexReasoningPresetSchema = type({ + "effort?": "unknown", +}); -const codexModelEntrySchema = z - .object({ - slug: z.unknown().optional(), - id: z.unknown().optional(), - display_name: z.unknown().optional(), - context_window: z.unknown().optional(), - default_reasoning_level: z.unknown().optional(), - supported_reasoning_levels: z.unknown().optional(), - input_modalities: z.unknown().optional(), - supported_in_api: z.unknown().optional(), - priority: z.unknown().optional(), - prefer_websockets: z.unknown().optional(), - }) - .loose(); +const codexModelEntrySchema = type({ + "slug?": "unknown", + "id?": "unknown", + "display_name?": "unknown", + "context_window?": "unknown", + "default_reasoning_level?": "unknown", + "supported_reasoning_levels?": "unknown", + "input_modalities?": "unknown", + "supported_in_api?": "unknown", + "priority?": "unknown", + "prefer_websockets?": "unknown", +}); -const codexModelsResponseSchema = z - .object({ - models: z.array(z.unknown()).optional(), - data: z.array(z.unknown()).optional(), - }) - .loose(); - -type CodexModelEntry = z.infer; +const codexModelsResponseSchema = type({ + "models?": "unknown[]", + "data?": "unknown[]", +}); +type CodexModelEntry = typeof codexModelEntrySchema.infer; interface NormalizedCodexModel { model: ModelSpec<"openai-codex-responses">; priority: number; @@ -216,12 +209,12 @@ function isAbortError(error: unknown): error is Error { } function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"openai-codex-responses">[] | null { - const parsedResponse = codexModelsResponseSchema.safeParse(payload); - if (!parsedResponse.success) { + const parsedResponse = codexModelsResponseSchema(payload); + if (parsedResponse instanceof type.errors) { return null; } - const entries = parsedResponse.data.models ?? parsedResponse.data.data ?? []; + const entries = parsedResponse.models ?? parsedResponse.data ?? []; const normalized: NormalizedCodexModel[] = []; for (const entry of entries) { const model = normalizeCodexModelEntry(entry, baseUrl); @@ -241,12 +234,12 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope } function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCodexModel | null { - const parsedEntry = codexModelEntrySchema.safeParse(entry); - if (!parsedEntry.success) { + const parsedEntry = codexModelEntrySchema(entry); + if (parsedEntry instanceof type.errors) { return null; } - const payload: CodexModelEntry = parsedEntry.data; + const payload: CodexModelEntry = parsedEntry; const slug = toNonEmptyString(payload.slug) ?? toNonEmptyString(payload.id); if (!slug) { return null; @@ -295,11 +288,11 @@ function supportsReasoning(defaultReasoningLevel: unknown, supportedReasoningLev } for (const level of supportedReasoningLevels) { - const parsedLevel = codexReasoningPresetSchema.safeParse(level); - if (!parsedLevel.success) { + const parsedLevel = codexReasoningPresetSchema(level); + if (parsedLevel instanceof type.errors) { continue; } - const effort = toNonEmptyString(parsedLevel.data.effort)?.toLowerCase(); + const effort = toNonEmptyString(parsedLevel.effort)?.toLowerCase(); if (effort && effort !== "none") { return true; } diff --git a/packages/catalog/src/discovery/cursor.ts b/packages/catalog/src/discovery/cursor.ts index 8f337b80c..880aefec5 100644 --- a/packages/catalog/src/discovery/cursor.ts +++ b/packages/catalog/src/discovery/cursor.ts @@ -1,6 +1,6 @@ import * as http2 from "node:http2"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { getBundledModels } from "../models"; import { toModelSpec } from "../provider-models/bundled-references"; import type { Model, ModelSpec } from "../types"; @@ -13,27 +13,34 @@ const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels"; const DEFAULT_CONTEXT_WINDOW = 200_000; const DEFAULT_MAX_TOKENS = 64_000; -const OptionalDisplayNameSchema = z.string().optional().catch(undefined); -const CursorAliasesSchema = z - .array(z.unknown()) - .optional() - .catch([]) - .transform(aliases => (aliases ?? []).filter((alias: unknown): alias is string => typeof alias === "string")); - -const CursorModelDetailsSchema = z.object({ - modelId: z.string(), - displayName: OptionalDisplayNameSchema, - displayNameShort: OptionalDisplayNameSchema, - displayModelId: OptionalDisplayNameSchema, - aliases: CursorAliasesSchema, - thinkingDetails: z.unknown().optional(), +const OptionalDisplayNameSchema = type("unknown").pipe(raw => (typeof raw === "string" ? raw : undefined)); +const CursorAliasesSchema = type("unknown").pipe(raw => { + if (Array.isArray(raw)) { + return raw.filter((alias: unknown): alias is string => typeof alias === "string"); + } + return []; }); -const CursorDecodedResponseSchema = z.object({ - models: z.array(z.unknown()).optional().catch([]), +const CursorModelDetailsSchema = type({ + modelId: "string", + displayName: OptionalDisplayNameSchema.default(undefined), + displayNameShort: OptionalDisplayNameSchema.default(undefined), + displayModelId: OptionalDisplayNameSchema.default(undefined), + aliases: CursorAliasesSchema.default(() => []), + "thinkingDetails?": "unknown", }); -type CursorModelDetailsValue = z.infer; +const CursorModelsInnerSchema = type("unknown[]"); +const ResilientCursorModelsSchema = type("unknown").pipe(raw => { + const out = CursorModelsInnerSchema(raw); + return out instanceof type.errors ? [] : out; +}); + +const CursorDecodedResponseSchema = type({ + models: ResilientCursorModelsSchema.default(() => []), +}); + +type CursorModelDetailsValue = typeof CursorModelDetailsSchema.infer; /** * Options for fetching dynamic Cursor models from `GetUsableModels`. @@ -74,13 +81,13 @@ export async function fetchCursorUsableModels( return null; } const decoded = decodeGetUsableModelsResponse(responseBuffer); - const parsedDecoded = CursorDecodedResponseSchema.safeParse(decoded); - if (!parsedDecoded.success) { + const parsedDecoded = CursorDecodedResponseSchema(decoded); + if (parsedDecoded instanceof type.errors) { return null; } const references = createCursorReferenceMap(); - return normalizeCursorModels(parsedDecoded.data.models, options.baseUrl, references); + return normalizeCursorModels(parsedDecoded.models, options.baseUrl, references); } catch { return null; } @@ -254,12 +261,12 @@ function normalizeCursorModel( baseUrlOverride: string | undefined, references: Map>, ): ModelSpec<"cursor-agent"> | null { - const parsedModel = CursorModelDetailsSchema.safeParse(model); - if (!parsedModel.success) { + const parsedModel = CursorModelDetailsSchema(model); + if (parsedModel instanceof type.errors) { return null; } - const details = parsedModel.data; + const details = parsedModel; const id = details.modelId.trim(); if (!id) { return null; diff --git a/packages/catalog/src/discovery/gemini.ts b/packages/catalog/src/discovery/gemini.ts index 13fa4522d..a64aa5ff9 100644 --- a/packages/catalog/src/discovery/gemini.ts +++ b/packages/catalog/src/discovery/gemini.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import { getBundledModels } from "../models"; import { toModelSpec } from "../provider-models/bundled-references"; import type { FetchImpl, Model, ModelSpec } from "../types"; @@ -7,36 +7,45 @@ const GOOGLE_GENERATIVE_AI_BASE_URL = "https://generativelanguage.googleapis.com const DEFAULT_PAGE_SIZE = 100; const DEFAULT_MAX_PAGES = 25; -const geminiModelListItemSchema = z.object({ - name: z.string().optional().catch(undefined), - displayName: z.string().optional().catch(undefined), - supportedGenerationMethods: z.array(z.string()).optional(), - inputTokenLimit: z.number().finite().optional().catch(undefined), - outputTokenLimit: z.number().finite().optional().catch(undefined), +const resilientString = type("unknown").pipe(val => { + if (val === undefined) return undefined; + const out = type("string")(val); + return out instanceof type.errors ? undefined : out; }); -const geminiModelListResponseSchema = z.object({ - models: z - .array(z.unknown()) - .optional() - .transform(items => { - if (!items) { - return []; - } - const parsedItems: GeminiModelListItem[] = []; - for (const item of items) { - const parsed = geminiModelListItemSchema.safeParse(item); - if (parsed.success) { - parsedItems.push(parsed.data); - } - } - return parsedItems; - }), - nextPageToken: z.string().optional().catch(undefined), +const resilientNumber = type("unknown").pipe(val => { + if (val === undefined) return undefined; + const out = type("number")(val); + return out instanceof type.errors ? undefined : out; }); -type GeminiModelListItem = z.infer; +const geminiModelListItemSchema = type({ + "name?": resilientString, + "displayName?": resilientString, + "supportedGenerationMethods?": "string[]", + "inputTokenLimit?": resilientNumber, + "outputTokenLimit?": resilientNumber, +}); +type GeminiModelListItem = typeof geminiModelListItemSchema.infer; + +const modelsSchema = type("unknown[]") + .pipe(items => { + const parsedItems: GeminiModelListItem[] = []; + for (const item of items) { + const parsed = geminiModelListItemSchema(item); + if (!(parsed instanceof type.errors)) { + parsedItems.push(parsed); + } + } + return parsedItems; + }) + .default(() => []); + +const geminiModelListResponseSchema = type({ + models: modelsSchema, + "nextPageToken?": resilientString, +}); /** * Configuration for Google Generative AI model discovery. */ @@ -103,19 +112,19 @@ export async function fetchGeminiModels( return null; } - const parsed = geminiModelListResponseSchema.safeParse(payload); - if (!parsed.success) { + const parsed = geminiModelListResponseSchema(payload); + if (parsed instanceof type.errors) { return null; } - for (const item of parsed.data.models) { + for (const item of parsed.models) { const model = normalizeModel(item, baseUrl, bundledById); if (model) { modelsById.set(model.id, model); } } - const token = normalizePageToken(parsed.data.nextPageToken); + const token = normalizePageToken(parsed.nextPageToken); if (!token) { break; } diff --git a/packages/catalog/src/discovery/openai-compatible.ts b/packages/catalog/src/discovery/openai-compatible.ts index 73f2132eb..a3669554b 100644 --- a/packages/catalog/src/discovery/openai-compatible.ts +++ b/packages/catalog/src/discovery/openai-compatible.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { Api, FetchImpl, ModelSpec, Provider } from "../types"; const MODELS_PATH = "/models"; @@ -32,28 +32,23 @@ export interface OpenAICompatibleModelsEnvelope { [key: string]: unknown; } -const openAICompatibleModelRecordSchema = z - .object({ - id: z.string().min(1), - name: z.string().optional().nullable(), - object: z.unknown().optional(), - owned_by: z.unknown().optional(), - }) - .loose(); +const openAICompatibleModelRecordSchema = type({ + id: "string >= 1", + "name?": "string | null", + "object?": "unknown", + "owned_by?": "unknown", +}); -const openAICompatibleModelsEnvelopeSchema = z - .object({ - data: z.unknown().optional(), - models: z.unknown().optional(), - result: z.unknown().optional(), - items: z.unknown().optional(), - }) - .loose(); +const openAICompatibleModelsEnvelopeSchema = type({ + "data?": "unknown", + "models?": "unknown", + "result?": "unknown", + "items?": "unknown", +}); -const openAICompatibleModelsPayloadSchema = z.union([z.array(z.unknown()), openAICompatibleModelsEnvelopeSchema]); - -type ParsedOpenAICompatibleModelRecord = z.infer; +const openAICompatibleModelsPayloadSchema = type("unknown[]").or(openAICompatibleModelsEnvelopeSchema); +type ParsedOpenAICompatibleModelRecord = typeof openAICompatibleModelRecordSchema.infer; /** * Context passed to custom OpenAI-compatible model mappers. */ @@ -196,22 +191,17 @@ function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecor } function extractModelEntriesFromNode(node: unknown): ParsedOpenAICompatibleModelRecord[] | null { - const parsedPayload = openAICompatibleModelsPayloadSchema.safeParse(node); - if (!parsedPayload.success) { + const parsedPayload = openAICompatibleModelsPayloadSchema(node); + if (parsedPayload instanceof type.errors) { return null; } - if (Array.isArray(parsedPayload.data)) { - const parsedEntries = parsedPayload.data - .map(entry => openAICompatibleModelRecordSchema.safeParse(entry)) - .flatMap(entry => (entry.success ? [entry.data] : [])); + if (Array.isArray(parsedPayload)) { + const parsedEntries = parsedPayload + .map(entry => openAICompatibleModelRecordSchema(entry)) + .flatMap(entry => (entry instanceof type.errors ? [] : [entry])); return parsedEntries; } - for (const candidate of [ - parsedPayload.data.data, - parsedPayload.data.models, - parsedPayload.data.result, - parsedPayload.data.items, - ]) { + for (const candidate of [parsedPayload.data, parsedPayload.models, parsedPayload.result, parsedPayload.items]) { if (candidate === undefined) { continue; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f23f85b17..34f22d337 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Added +- Added evaluation macro syntactic parsing, evaluation, and expander integration tests (`macro-syntax`, `macro-evaluator`, and `macro-expand`) - Added support for OpenRouter fallback in Perplexity web search when direct Perplexity API keys fail or are unavailable - Added support for streaming the Perplexity Responses API (`/v1/responses`) via the `PI_PERPLEXITY_RESPONSES=1` environment variable - Added `omp ttsr` top-level CLI command to inspect and test Time-Traveling Stream Rules @@ -16,12 +18,14 @@ ### Changed +- Replaced internal schema validation and `@sinclair/typebox` polyfills across all agent tools and configurations from Zod to ArkType - Changed advisor model calls and overflow-compaction tasks to inherit and propagate primary telemetry spans, usage, and cost tracking - Changed PDF read output to replace `` placeholders with clickable `read :.png` handles, including line-range and multi-range reads - Changed the built-in `ts-no-any` rule to recommend a schema parse at trust boundaries and `in`-narrowing (instead of an inline `as`-cast) when reading fields off `unknown` ### Fixed +- Fixed edit seen-line guard mismatch assertion message formatting to report the actual state instead of generic failure notices - Fixed Perplexity web search to use shared OpenAI streaming transports while preserving streamed sources, citations, and related questions - Fixed `read :` errors for unknown PDF images to surface available extracted image names - Fixed puppeteer stealth scripts to use cached Reflect methods (`Reflect_get`, `Reflect_apply`) and `Reflect.apply` instead of live `Reflect`/`Function.prototype.apply` calls, preventing page tampering from leaking through proxy traps. diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index 9c95fc4af..774073128 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "bun:test"; import type { AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core"; +import { type } from "arktype"; import { createAdvisorMessageCard } from "../../modes/components/advisor-message"; import { getThemeByName } from "../../modes/theme/theme"; import { formatSessionHistoryMarkdown } from "../../session/session-history-format"; @@ -116,6 +117,16 @@ describe("advisor", () => { expect(result.details).toEqual({ note: "x", severity: "concern" }); expect(result.useless).toBe(true); }); + + it("validates parameters using ArkType", () => { + const onAdvice = vi.fn(); + const tool = new AdviseTool(onAdvice); + const valid = tool.parameters({ note: "x", severity: "concern" }); + expect(valid instanceof type.errors).toBe(false); + + const invalid = tool.parameters({ note: 123, severity: "invalid" as any }); + expect(invalid instanceof type.errors).toBe(true); + }); }); describe("advice delivery policy", () => { diff --git a/packages/coding-agent/src/advisor/advise-tool.ts b/packages/coding-agent/src/advisor/advise-tool.ts index 427f27185..1c9e7d908 100644 --- a/packages/coding-agent/src/advisor/advise-tool.ts +++ b/packages/coding-agent/src/advisor/advise-tool.ts @@ -7,20 +7,17 @@ import type { AgentToolUpdateCallback, } from "@oh-my-pi/pi-agent-core"; import { escapeXmlText } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" }; -const adviseSchema = z.object({ - note: z - .string() - .describe("One concrete piece of advice for the agent you are watching. Terse, specific, actionable."), - severity: z - .enum(["nit", "concern", "blocker"]) - .optional() - .describe("How strongly to weigh this. Omit for a plain nit."), +const adviseSchema = type({ + note: type("string").describe( + "One concrete piece of advice for the agent you are watching. Terse, specific, actionable.", + ), + "severity?": type("'nit' | 'concern' | 'blocker'").describe("How strongly to weigh this. Omit for a plain nit."), }); -export type AdviseParams = z.infer; +export type AdviseParams = typeof adviseSchema.infer; export type AdvisorSeverity = "nit" | "concern" | "blocker"; diff --git a/packages/coding-agent/src/autoresearch/tools/init-experiment.ts b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts index 9757ecdfb..33881b010 100644 --- a/packages/coding-agent/src/autoresearch/tools/init-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import { Text } from "@oh-my-pi/pi-tui"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; @@ -16,21 +16,18 @@ export const HARNESS_FILENAME = "autoresearch.sh"; export const DEFAULT_HARNESS_COMMAND = `bash ${HARNESS_FILENAME}`; const HARNESS_COMMIT_TITLE = "autoresearch: harness setup"; -const initExperimentSchema = z.object({ - name: z.string().describe("experiment name"), - goal: z.string().describe("session goal").optional(), - primary_metric: z.string().describe("primary metric name"), - metric_unit: z.string().describe("metric unit (e.g. ms, µs, mb)").optional(), - direction: z - .enum(["lower", "higher"] as const) - .describe("better direction (default lower)") - .optional(), - secondary_metrics: z.array(z.string()).describe("secondary metric names").optional(), - scope_paths: z.array(z.string()).describe("expected-to-modify paths").optional(), - off_limits: z.array(z.string()).describe("off-limits paths").optional(), - constraints: z.array(z.string()).describe("free-form constraints").optional(), - max_iterations: z.number().describe("soft iteration cap per segment").optional(), - new_segment: z.boolean().describe("bump to a new segment in existing session").optional(), +const initExperimentSchema = type({ + name: "string", + "goal?": "string", + primary_metric: "string", + "metric_unit?": "string", + "direction?": "'lower' | 'higher'", + "secondary_metrics?": "string[]", + "scope_paths?": "string[]", + "off_limits?": "string[]", + "constraints?": "string[]", + "max_iterations?": "number", + "new_segment?": "boolean", }); interface InitExperimentDetails { diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts index 01e1c87c9..47a4cd3c8 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { Text } from "@oh-my-pi/pi-tui"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; @@ -36,23 +36,18 @@ import type { const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment", "update_notes"]; -const logExperimentSchema = z.object({ - metric: z.number().describe("primary metric value"), - status: z.enum(["keep", "discard", "crash", "checks_failed"] as const).describe("run outcome"), - description: z.string().describe("short run description"), - metrics: z.record(z.string(), z.number()).describe("secondary metrics").optional(), - asi: z.object({}).passthrough().describe("free-form structured metadata").optional(), - commit: z.string().describe("override recorded commit hash").optional(), - justification: z.string().describe("required when keeping a scope-deviating run").optional(), - flag_runs: z - .array( - z.object({ - run_id: z.number().describe("run id to flag"), - reason: z.string().describe("why this run is suspect"), - }), - ) - .describe("flag earlier runs as suspect") - .optional(), +const logExperimentSchema = type({ + metric: "number", + status: "'keep'|'discard'|'crash'|'checks_failed'", + description: "string", + "metrics?": { "[string]": "number" }, + "asi?": { "[string]": "unknown" }, + "commit?": "string", + "justification?": "string", + "flag_runs?": type({ + run_id: "number.integer", + reason: "string", + }).array(), }); export function createLogExperimentTool( diff --git a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts index 5e65ea524..0919e4643 100644 --- a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { Text } from "@oh-my-pi/pi-tui"; import { formatBytes } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { executeBash } from "../../exec/bash-executor"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; @@ -25,8 +25,8 @@ import { openAutoresearchStorageIfExists } from "../storage"; import type { AutoresearchToolFactoryOptions, RunDetails, RunExperimentProgressDetails } from "../types"; import { DEFAULT_HARNESS_COMMAND } from "./init-experiment"; -const runExperimentSchema = z.object({ - timeout_seconds: z.number().describe("timeout in seconds (default 600)").optional(), +const runExperimentSchema = type({ + "timeout_seconds?": type("number").describe("timeout in seconds (default 600)"), }); interface ProcessExecutionResult { diff --git a/packages/coding-agent/src/autoresearch/tools/update-notes.ts b/packages/coding-agent/src/autoresearch/tools/update-notes.ts index a9b80a63e..bfec01af4 100644 --- a/packages/coding-agent/src/autoresearch/tools/update-notes.ts +++ b/packages/coding-agent/src/autoresearch/tools/update-notes.ts @@ -1,5 +1,5 @@ import { Text } from "@oh-my-pi/pi-tui"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; @@ -8,9 +8,9 @@ import { buildExperimentState } from "../state"; import { openAutoresearchStorageIfExists } from "../storage"; import type { AutoresearchToolFactoryOptions } from "../types"; -const updateNotesSchema = z.object({ - body: z.string().describe("replacement notes body"), - append_idea: z.string().describe("append as bullet under Ideas instead of replacing body").optional(), +const updateNotesSchema = type({ + body: type("string").describe("replacement notes body"), + "append_idea?": type("string").describe("append as bullet under Ideas instead of replacing body"), }); interface UpdateNotesDetails { diff --git a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts index 17a1b6b42..79492fb1d 100644 --- a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts +++ b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts @@ -1,5 +1,5 @@ import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import analyzeFilePrompt from "../../../commit/agentic/prompts/analyze-file.md" with { type: "text" }; import type { CommitAgentState } from "../../../commit/agentic/state"; import type { NumstatEntry } from "../../../commit/types"; @@ -12,9 +12,9 @@ import type { TaskParams } from "../../../task/types"; import type { ToolSession } from "../../../tools"; import { getFilePriority } from "./git-file-diff"; -const analyzeFileSchema = z.object({ - files: z.array(z.string().describe("file path")).min(1), - goal: z.string().describe("analysis focus").optional(), +const analyzeFileSchema = type({ + files: "string[] >= 1", + goal: "string | undefined", }); const analyzeFileOutputSchema = { diff --git a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts index 289a06c80..7dcac0e69 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { CommitAgentState } from "../../../commit/agentic/state"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; @@ -131,9 +131,9 @@ function processDiffs(files: string[], diffs: Map): { result: st return { result: parts.join("\n\n"), truncatedFiles }; } -const gitFileDiffSchema = z.object({ - files: z.array(z.string().describe("file to diff")).min(1).max(10), - staged: z.boolean().describe("use staged changes (default true)").optional(), +const gitFileDiffSchema = type({ + files: type("string[]").atLeastLength(1).atMostLength(10).describe("files to diff"), + "staged?": type("boolean").describe("use staged changes (default true)"), }); export function createGitFileDiffTool(cwd: string, state: CommitAgentState): CustomTool { diff --git a/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts b/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts index 7fd3444f2..cd7f5c768 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts @@ -1,12 +1,14 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { DiffHunk, FileHunks } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; -const gitHunkSchema = z.object({ - file: z.string().describe("file path"), - hunks: z.array(z.number().describe("1-based hunk index")).min(1).optional(), - staged: z.boolean().describe("use staged changes (default true)").optional(), +const hunkIndexType = type("number").describe("1-based hunk index"); + +const gitHunkSchema = type({ + file: type("string").describe("file path"), + "hunks?": hunkIndexType.array().atLeastLength(1), + "staged?": type("boolean").describe("use staged changes (default true)"), }); function selectHunks(fileHunks: FileHunks, requested?: number[]): DiffHunk[] { diff --git a/packages/coding-agent/src/commit/agentic/tools/git-overview.ts b/packages/coding-agent/src/commit/agentic/tools/git-overview.ts index 3c77c37fd..892870e8d 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-overview.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-overview.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { CommitAgentState, GitOverviewSnapshot } from "../../../commit/agentic/state"; import { extractScopeCandidates } from "../../../commit/analysis/scope"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; @@ -42,9 +42,9 @@ function filterExcludedFiles(files: string[]): { filtered: string[]; excluded: s return { filtered, excluded }; } -const gitOverviewSchema = z.object({ - staged: z.boolean().describe("use staged changes (default true)").optional(), - include_untracked: z.boolean().describe("include untracked when unstaged").optional(), +const gitOverviewSchema = type({ + "staged?": "boolean", + "include_untracked?": "boolean", }); export function createGitOverviewTool(cwd: string, state: CommitAgentState): CustomTool { diff --git a/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts b/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts index d6ada728e..b11ac90a4 100644 --- a/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts +++ b/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts @@ -1,27 +1,30 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { CommitAgentState } from "../../../commit/agentic/state"; import { CHANGELOG_CATEGORIES, type ChangelogCategory } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; -const changelogEntryProperties = CHANGELOG_CATEGORIES.reduce>( - (acc, category) => { - acc[category] = z.array(z.string()).optional(); - return acc; - }, - {} as Record, -); +const changelogCategoryProperties = { + "Breaking Changes?": "string[]", + "Added?": "string[]", + "Changed?": "string[]", + "Deprecated?": "string[]", + "Removed?": "string[]", + "Fixed?": "string[]", + "Security?": "string[]", +} as const; -const changelogEntriesSchema = z.object(changelogEntryProperties); -const changelogDeletionsSchema = z.object(changelogEntryProperties).describe("entries to remove"); - -const changelogEntrySchema = z.object({ - path: z.string(), - entries: changelogEntriesSchema, - deletions: changelogDeletionsSchema.optional(), +const changelogEntriesSchema = type({ + ...changelogCategoryProperties, }); -const proposeChangelogSchema = z.object({ - entries: z.array(changelogEntrySchema), +const changelogEntrySchema = type({ + path: "string", + entries: changelogEntriesSchema, + "deletions?": changelogEntriesSchema, +}); + +const proposeChangelogSchema = type({ + entries: changelogEntrySchema.array(), }); interface ChangelogResponse { diff --git a/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts b/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts index a5953d8b4..5a9c75fa0 100644 --- a/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts +++ b/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { CommitAgentState } from "../../../commit/agentic/state"; import { capDetails, @@ -15,12 +15,12 @@ import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; import { commitTypeSchema, detailSchema } from "./schemas.js"; -const proposeCommitSchema = z.object({ +const proposeCommitSchema = type({ type: commitTypeSchema, - scope: z.union([z.string(), z.null()]), - summary: z.string(), - details: z.array(detailSchema), - issue_refs: z.array(z.string()), + scope: "string | null", + summary: "string", + details: detailSchema.array(), + issue_refs: "string[]", }); interface ProposalResponse { diff --git a/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts b/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts index abb25fd43..f1adc42f6 100644 --- a/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts +++ b/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts @@ -1,9 +1,9 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; -const recentCommitsSchema = z.object({ - count: z.number().min(1).max(50).describe("commit count").optional(), +const recentCommitsSchema = type({ + "count?": "1 <= number <= 50", }); interface RecentCommitStats { diff --git a/packages/coding-agent/src/commit/agentic/tools/schemas.ts b/packages/coding-agent/src/commit/agentic/tools/schemas.ts index 634165839..d39572934 100644 --- a/packages/coding-agent/src/commit/agentic/tools/schemas.ts +++ b/packages/coding-agent/src/commit/agentic/tools/schemas.ts @@ -1,23 +1,11 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; -export const commitTypeSchema = z.enum([ - "feat", - "fix", - "refactor", - "perf", - "docs", - "test", - "build", - "ci", - "chore", - "style", - "revert", -] as const); +export const commitTypeSchema = type( + "'feat' | 'fix' | 'refactor' | 'perf' | 'docs' | 'test' | 'build' | 'ci' | 'chore' | 'style' | 'revert'", +); -export const detailSchema = z.object({ - text: z.string(), - changelog_category: z - .enum(["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security", "Breaking Changes"]) - .optional(), - user_visible: z.boolean().optional(), +export const detailSchema = type({ + text: "string", + "changelog_category?": "'Added' | 'Changed' | 'Fixed' | 'Deprecated' | 'Removed' | 'Security' | 'Breaking Changes'", + "user_visible?": "boolean", }); diff --git a/packages/coding-agent/src/commit/agentic/tools/split-commit.ts b/packages/coding-agent/src/commit/agentic/tools/split-commit.ts index 72042b311..a6b43d409 100644 --- a/packages/coding-agent/src/commit/agentic/tools/split-commit.ts +++ b/packages/coding-agent/src/commit/agentic/tools/split-commit.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; import type { CommitAgentState, SplitCommitGroup, SplitCommitPlan } from "../../../commit/agentic/state"; import { computeDependencyOrder } from "../../../commit/agentic/topo-sort"; import { @@ -15,32 +15,28 @@ import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; import { commitTypeSchema, detailSchema } from "./schemas.js"; -const hunkSelectorSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("all") }), - z.object({ type: z.literal("indices"), indices: z.array(z.number()).min(1) }), - z.object({ type: z.literal("lines"), start: z.number(), end: z.number() }), -]); +const hunkSelectorSchema = type({ type: "'all'" }) + .or({ type: "'indices'", indices: "number[]" }) + .or({ type: "'lines'", start: "number", end: "number" }); -const fileChangeSchema = z.object({ - path: z.string(), +const fileChangeSchema = type({ + path: "string", hunks: hunkSelectorSchema, }); -const splitCommitSchema = z.object({ - commits: z - .array( - z.object({ - changes: z.array(fileChangeSchema).min(1), - type: commitTypeSchema, - scope: z.union([z.string(), z.null()]), - summary: z.string(), - details: z.array(detailSchema).optional(), - issue_refs: z.array(z.string()).optional(), - rationale: z.string().optional(), - dependencies: z.array(z.number()).optional(), - }), - ) - .min(2), +const commitItemSchema = type({ + changes: fileChangeSchema.array(), + type: commitTypeSchema, + scope: "string | null", + summary: "string", + "details?": detailSchema.array(), + "issue_refs?": "string[]", + "rationale?": "string", + "dependencies?": "number[]", +}); + +const splitCommitSchema = type({ + commits: commitItemSchema.array(), }); interface SplitCommitResponse { diff --git a/packages/coding-agent/src/commit/analysis/summary.ts b/packages/coding-agent/src/commit/analysis/summary.ts index b021f4cdf..8e371e183 100644 --- a/packages/coding-agent/src/commit/analysis/summary.ts +++ b/packages/coding-agent/src/commit/analysis/summary.ts @@ -2,19 +2,21 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import summarySystemPrompt from "../../commit/prompts/summary-system.md" with { type: "text" }; import summaryUserPrompt from "../../commit/prompts/summary-user.md" with { type: "text" }; import type { CommitSummary } from "../../commit/types"; import { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall } from "../utils"; +const SummaryToolSchema = type({ + summary: "string", +}); + const SummaryTool = { name: "create_commit_summary", description: "Generate the summary line for a conventional commit message.", - parameters: z.object({ - summary: z.string(), - }), + parameters: SummaryToolSchema, }; export interface SummaryInput { @@ -83,7 +85,7 @@ function renderSummaryPrompt({ function parseSummaryFromResponse(message: AssistantMessage, commitType: string, scope: string | null): CommitSummary { const toolCall = extractToolCall(message, "create_commit_summary"); if (toolCall) { - const parsed = validateToolCall([SummaryTool], toolCall) as z.infer<(typeof SummaryTool)["parameters"]>; + const parsed = validateToolCall([SummaryTool], toolCall) as (typeof SummaryToolSchema)["infer"]; return { summary: stripTypePrefix(parsed.summary, commitType, scope) }; } const text = extractTextContent(message); diff --git a/packages/coding-agent/src/commit/changelog/generate.ts b/packages/coding-agent/src/commit/changelog/generate.ts index 40d86feb3..bab948926 100644 --- a/packages/coding-agent/src/commit/changelog/generate.ts +++ b/packages/coding-agent/src/commit/changelog/generate.ts @@ -2,25 +2,29 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" }; import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" }; -import { CHANGELOG_CATEGORIES, type ChangelogCategory, type ChangelogGenerationResult } from "../../commit/types"; +import type { ChangelogGenerationResult } from "../../commit/types"; import { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../utils"; -const changelogEntryShape = Object.fromEntries( - CHANGELOG_CATEGORIES.map(c => [c, z.array(z.string()).optional()] as const), -) as Record>>; - -const changelogEntriesSchema = z.object(changelogEntryShape); +// Build the changelog entry schema with arktype +// Each category maps to an optional array of strings +const changelogEntriesSchema = type({ + "Breaking Changes?": "string[]", + "Added?": "string[]", + "Changed?": "string[]", + "Deprecated?": "string[]", + "Removed?": "string[]", + "Fixed?": "string[]", + "Security?": "string[]", +}); export const changelogTool = { name: "create_changelog_entries", description: "Generate changelog entries grouped by Keep a Changelog categories.", - parameters: z.object({ - entries: changelogEntriesSchema, - }), + parameters: type({ entries: changelogEntriesSchema }), }; export interface ChangelogPromptInput { @@ -68,7 +72,7 @@ export async function generateChangelogEntries({ function parseChangelogResponse(message: AssistantMessage): ChangelogGenerationResult { const toolCall = extractToolCall(message, "create_changelog_entries"); if (toolCall) { - const parsed = validateToolCall([changelogTool], toolCall) as z.infer<(typeof changelogTool)["parameters"]>; + const parsed = validateToolCall([changelogTool], toolCall) as typeof changelogTool.parameters.infer; return { entries: parsed.entries ?? {} }; } diff --git a/packages/coding-agent/src/commit/shared-llm.ts b/packages/coding-agent/src/commit/shared-llm.ts index 6512450d4..ecf909259 100644 --- a/packages/coding-agent/src/commit/shared-llm.ts +++ b/packages/coding-agent/src/commit/shared-llm.ts @@ -1,36 +1,29 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { validateToolCall } from "@oh-my-pi/pi-ai"; -import { z } from "zod/v4"; +import { type as t, validateToolCall } from "@oh-my-pi/pi-ai"; import type { ChangelogCategory, ConventionalAnalysis } from "./types"; import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "./utils"; -const changelogCategoryLiteral = z.enum([ - "Added", - "Changed", - "Fixed", - "Deprecated", - "Removed", - "Security", - "Breaking Changes", -]); +const changelogCategoryLiteral = t( + "'Added' | 'Changed' | 'Fixed' | 'Deprecated' | 'Removed' | 'Security' | 'Breaking Changes'", +); /** - * Shared Zod schema for the `create_conventional_analysis` tool used by + * Shared arktype schema for the `create_conventional_analysis` tool used by * both the single-pass analysis call and the map-reduce reduce phase. Schemas * are identical across phases — only the surrounding tool `description` * differs to reflect the input the phase is summarizing. */ -export const conventionalAnalysisParameters = z.object({ - type: z.enum(["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"]), - scope: z.union([z.string(), z.null()]), - details: z.array( - z.object({ - text: z.string(), - changelog_category: changelogCategoryLiteral.optional(), - user_visible: z.boolean().optional(), - }), - ), - issue_refs: z.array(z.string()), +const detailItem = t({ + text: "string", + "changelog_category?": changelogCategoryLiteral, + "user_visible?": "boolean", +}); + +export const conventionalAnalysisParameters = t({ + type: "'feat' | 'fix' | 'refactor' | 'docs' | 'test' | 'chore' | 'style' | 'perf' | 'build' | 'ci' | 'revert'", + scope: "string | null", + details: detailItem.array(), + issue_refs: "string[]", }); export interface ConventionalAnalysisTool { @@ -68,7 +61,7 @@ export function parseConventionalAnalysisResponse( ): ConventionalAnalysis { const toolCall = extractToolCall(message, tool.name); if (toolCall) { - const parsed = validateToolCall([tool], toolCall) as z.infer; + const parsed = validateToolCall([tool], toolCall) as any; return normalizeAnalysis(parsed); } const text = extractTextContent(message); diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index eb9866554..7769350f6 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -1,8 +1,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import type { Type } from "arktype"; import { JSONC, YAML } from "bun"; -import type { ZodType } from "zod/v4"; /** Minimal subset of the AJV ConfigSchemaError shape this module actually relies on. */ interface ConfigSchemaError { @@ -55,7 +55,7 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) { export interface IConfigFile { readonly id: string; - readonly schema: ZodType; + readonly schema: Type; path?(): string; load(): T | null; invalidate?(): void; @@ -129,7 +129,7 @@ export class ConfigFile implements IConfigFile { constructor( readonly id: string, - readonly schema: ZodType, + readonly schema: Type, configPath: string = path.join(getAgentDir(), `${id}.yml`), ) { this.#basePath = configPath; @@ -193,10 +193,10 @@ export class ConfigFile implements IConfigFile { } createDefault(): T { - const parsed = this.schema.safeParse({}); - if (parsed.success) return parsed.data; - const fallback = this.schema.safeParse(undefined); - if (fallback.success) return fallback.data; + const parsed = this.schema({}); + if (!(parsed instanceof Error)) return parsed as T; + const fallback = this.schema(undefined); + if (!(fallback instanceof Error)) return fallback as T; throw new ConfigError(this.id, undefined, { err: new Error("Schema produced no default value"), stage: "createDefault", @@ -219,19 +219,17 @@ export class ConfigFile implements IConfigFile { throw new Error(`Invalid config file path: ${this.#basePath}`); } - const checked = this.schema.safeParse(parsed); - if (!checked.success) { + const checked = this.schema(parsed); + if (checked instanceof Error) { const schemaErrors: ConfigSchemaError[] = []; - for (const issue of checked.error.issues) { - const instancePath = issue.path.length === 0 ? "" : `/${issue.path.map(String).join("/")}`; - schemaErrors.push({ instancePath, message: issue.message }); - if (schemaErrors.length >= 50) break; - } + // arktype errors are Error instances with a message property + // Extract the error message as a single schema error + schemaErrors.push({ instancePath: "root", message: checked.message }); const error = new ConfigError(this.id, schemaErrors); logger.warn("Failed to parse config file", { path: this.path(), error }); return this.#storeCache({ error, status: "error" }); } - const value = checked.data; + const value = checked as T; try { this.#auxValidate?.(value); } catch (error) { diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts index ea9c755fa..916c925d9 100644 --- a/packages/coding-agent/src/config/models-config-schema.ts +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -1,73 +1,72 @@ -import { z } from "zod/v4"; +import { type } from "arktype"; -const OpenRouterRoutingSchema = z.object({ - only: z.array(z.string()).optional(), - order: z.array(z.string()).optional(), +const OpenRouterRoutingSchema = type({ + "only?": "string[]", + "order?": "string[]", }); -const VercelGatewayRoutingSchema = z.object({ - only: z.array(z.string()).optional(), - order: z.array(z.string()).optional(), +const VercelGatewayRoutingSchema = type({ + "only?": "string[]", + "order?": "string[]", }); -const ReasoningEffortMapSchema = z.object({ - minimal: z.string().optional(), - low: z.string().optional(), - medium: z.string().optional(), - high: z.string().optional(), - xhigh: z.string().optional(), +const ReasoningEffortMapSchema = type({ + "minimal?": "string", + "low?": "string", + "medium?": "string", + "high?": "string", + "xhigh?": "string", }); -const OpenAICompatFieldsSchema = z.object({ - supportsStore: z.boolean().optional(), - supportsDeveloperRole: z.boolean().optional(), - supportsMultipleSystemMessages: z.boolean().optional(), - supportsReasoningEffort: z.boolean().optional(), - reasoningEffortMap: ReasoningEffortMapSchema.optional(), - maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(), - supportsUsageInStreaming: z.boolean().optional(), - requiresToolResultName: z.boolean().optional(), - requiresMistralToolIds: z.boolean().optional(), - requiresAssistantAfterToolResult: z.boolean().optional(), - requiresThinkingAsText: z.boolean().optional(), - reasoningContentField: z.enum(["reasoning_content", "reasoning", "reasoning_text"]).optional(), - requiresReasoningContentForToolCalls: z.boolean().optional(), - allowsSyntheticReasoningContentForToolCalls: z.boolean().optional(), - requiresAssistantContentForToolCalls: z.boolean().optional(), - supportsToolChoice: z.boolean().optional(), - supportsForcedToolChoice: z.boolean().optional(), - disableReasoningOnForcedToolChoice: z.boolean().optional(), - disableReasoningOnToolChoice: z.boolean().optional(), - thinkingFormat: z.enum(["openai", "openrouter", "zai", "qwen", "qwen-chat-template"]).optional(), - openRouterRouting: OpenRouterRoutingSchema.optional(), - vercelGatewayRouting: VercelGatewayRoutingSchema.optional(), - extraBody: z.record(z.string(), z.unknown()).optional(), - cacheControlFormat: z.enum(["anthropic"]).optional(), - supportsStrictMode: z.boolean().optional(), - toolStrictMode: z.enum(["all_strict", "none"]).optional(), - streamIdleTimeoutMs: z.number().nonnegative().optional(), - supportsLongPromptCacheRetention: z.boolean().optional(), - supportsReasoningParams: z.boolean().optional(), - alwaysSendMaxTokens: z.boolean().optional(), - strictResponsesPairing: z.boolean().optional(), +const OpenAICompatFields = { + "supportsStore?": "boolean", + "supportsDeveloperRole?": "boolean", + "supportsMultipleSystemMessages?": "boolean", + "supportsReasoningEffort?": "boolean", + "reasoningEffortMap?": ReasoningEffortMapSchema, + "maxTokensField?": '"max_completion_tokens" | "max_tokens"', + "supportsUsageInStreaming?": "boolean", + "requiresToolResultName?": "boolean", + "requiresMistralToolIds?": "boolean", + "requiresAssistantAfterToolResult?": "boolean", + "requiresThinkingAsText?": "boolean", + "reasoningContentField?": '"reasoning_content" | "reasoning" | "reasoning_text"', + "requiresReasoningContentForToolCalls?": "boolean", + "allowsSyntheticReasoningContentForToolCalls?": "boolean", + "requiresAssistantContentForToolCalls?": "boolean", + "supportsToolChoice?": "boolean", + "supportsForcedToolChoice?": "boolean", + "disableReasoningOnForcedToolChoice?": "boolean", + "disableReasoningOnToolChoice?": "boolean", + "thinkingFormat?": '"openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"', + "openRouterRouting?": OpenRouterRoutingSchema, + "vercelGatewayRouting?": VercelGatewayRoutingSchema, + "extraBody?": { "[string]": "unknown" }, + "cacheControlFormat?": '"anthropic"', + "supportsStrictMode?": "boolean", + "toolStrictMode?": '"all_strict" | "none"', + "streamIdleTimeoutMs?": "number >= 0", + "supportsLongPromptCacheRetention?": "boolean", + "supportsReasoningParams?": "boolean", + "alwaysSendMaxTokens?": "boolean", + "strictResponsesPairing?": "boolean", // anthropic-messages compat flags (same `compat` slot, per-api interpretation) - requiresToolResultId: z.boolean().optional(), - replayUnsignedThinking: z.boolean().optional(), + "requiresToolResultId?": "boolean", + "replayUnsignedThinking?": "boolean", +} as const; + +const OpenAICompatFieldsSchema = type(OpenAICompatFields); + +export const OpenAICompatSchema = type({ + ...OpenAICompatFields, + "whenThinking?": OpenAICompatFieldsSchema, }); -export const OpenAICompatSchema = OpenAICompatFieldsSchema.extend({ - whenThinking: OpenAICompatFieldsSchema.optional(), -}); +const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh"'); -const EffortSchema = z.enum(["minimal", "low", "medium", "high", "xhigh"]); - -const ThinkingControlModeSchema = z.enum([ - "effort", - "budget", - "google-level", - "anthropic-adaptive", - "anthropic-budget-effort", -]); +const ThinkingControlModeSchema = type( + '"effort" | "budget" | "google-level" | "anthropic-adaptive" | "anthropic-budget-effort"', +); const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh"] as const; @@ -77,137 +76,141 @@ const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh"] as const; * `ThinkingConfig` (ordered `efforts`, never empty). Precedence mirrors the * old runtime: explicit `levels` beat the min..max range; `efforts` beats both. */ -const ModelThinkingSchema = z - .object({ - mode: ThinkingControlModeSchema, - efforts: z.array(EffortSchema).min(1).optional(), - defaultLevel: EffortSchema.optional(), - effortMap: ReasoningEffortMapSchema.optional(), - supportsDisplay: z.boolean().optional(), - // Legacy range vocabulary (pre-efforts configs). - minLevel: EffortSchema.optional(), - maxLevel: EffortSchema.optional(), - levels: z.array(EffortSchema).min(1).optional(), - }) - .refine( - value => +const ModelThinkingSchema = type({ + mode: ThinkingControlModeSchema, + "efforts?": EffortSchema.array(), + "defaultLevel?": EffortSchema, + "effortMap?": ReasoningEffortMapSchema, + "supportsDisplay?": "boolean", + // Legacy range vocabulary (pre-efforts configs). + "minLevel?": EffortSchema, + "maxLevel?": EffortSchema, + "levels?": EffortSchema.array(), +}) + .narrow( + (value, ctx) => value.efforts !== undefined || value.levels !== undefined || - (value.minLevel !== undefined && value.maxLevel !== undefined), - { - message: "thinking requires `efforts` (or legacy `levels`/`minLevel`+`maxLevel`)", - }, + (value.minLevel !== undefined && value.maxLevel !== undefined) || + ctx.mustBe("thinking with `efforts` (or legacy `levels`/`minLevel`+`maxLevel`)"), ) - .transform(({ efforts, levels, minLevel, maxLevel, mode, defaultLevel, effortMap, supportsDisplay }) => { - let resolved = efforts ?? levels; + .pipe((value: any) => { + let resolved = value.efforts ?? value.levels; if (!resolved) { - const minIndex = EFFORT_ORDER.indexOf(minLevel!); - const maxIndex = EFFORT_ORDER.indexOf(maxLevel!); + const minIndex = EFFORT_ORDER.indexOf(value.minLevel!); + const maxIndex = EFFORT_ORDER.indexOf(value.maxLevel!); resolved = EFFORT_ORDER.slice(minIndex, Math.max(minIndex, maxIndex) + 1); } return { - mode, + mode: value.mode, efforts: resolved, - ...(defaultLevel !== undefined && { defaultLevel }), - ...(effortMap !== undefined && { effortMap }), - ...(supportsDisplay !== undefined && { supportsDisplay }), + ...(value.defaultLevel !== undefined && { defaultLevel: value.defaultLevel }), + ...(value.effortMap !== undefined && { effortMap: value.effortMap }), + ...(value.supportsDisplay !== undefined && { supportsDisplay: value.supportsDisplay }), }; }); -const ModelDefinitionSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1).optional(), - api: z - .enum([ - "openai-completions", - "openai-responses", - "openai-codex-responses", - "azure-openai-responses", - "anthropic-messages", - "google-generative-ai", - "google-gemini-cli", - "google-vertex", - ]) - .optional(), - baseUrl: z.string().min(1).optional(), - reasoning: z.boolean().optional(), - thinking: ModelThinkingSchema.optional(), - input: z.array(z.enum(["text", "image"])).optional(), - supportsTools: z.boolean().optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cacheRead: z.number(), - cacheWrite: z.number(), - }) - .optional(), - premiumMultiplier: z.number().optional(), - contextWindow: z.number().optional(), - maxTokens: z.number().optional(), - omitMaxOutputTokens: z.boolean().optional(), - headers: z.record(z.string(), z.string()).optional(), - compat: OpenAICompatSchema.optional(), - contextPromotionTarget: z.string().min(1).optional(), +const ModelDefinitionSchema = type({ + id: "string", + "name?": "string", + "api?": + '"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"', + "baseUrl?": "string", + "reasoning?": "boolean", + "thinking?": ModelThinkingSchema, + "input?": '("text" | "image")[]', + "supportsTools?": "boolean", + "cost?": { + input: "number", + output: "number", + cacheRead: "number", + cacheWrite: "number", + }, + "premiumMultiplier?": "number", + "contextWindow?": "number", + "maxTokens?": "number", + "omitMaxOutputTokens?": "boolean", + "headers?": { "[string]": "string" }, + "compat?": OpenAICompatSchema, + "contextPromotionTarget?": "string", +}).narrow((value, ctx) => { + // Enforce id non-empty + if (typeof value.id === "string" && value.id.length === 0) { + return ctx.mustBe("id a non-empty string"); + } + if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) { + return ctx.mustBe("name a non-empty string"); + } + if (value.baseUrl !== undefined && typeof value.baseUrl === "string" && value.baseUrl.length === 0) { + return ctx.mustBe("baseUrl a non-empty string"); + } + if ( + value.contextPromotionTarget !== undefined && + typeof value.contextPromotionTarget === "string" && + value.contextPromotionTarget.length === 0 + ) { + return ctx.mustBe("contextPromotionTarget a non-empty string"); + } + return true; }); -export const ModelOverrideSchema = z.object({ - name: z.string().min(1).optional(), - reasoning: z.boolean().optional(), - thinking: ModelThinkingSchema.optional(), - input: z.array(z.enum(["text", "image"])).optional(), - supportsTools: z.boolean().optional(), - cost: z - .object({ - input: z.number().optional(), - output: z.number().optional(), - cacheRead: z.number().optional(), - cacheWrite: z.number().optional(), - }) - .optional(), - premiumMultiplier: z.number().optional(), - contextWindow: z.number().optional(), - maxTokens: z.number().optional(), - omitMaxOutputTokens: z.boolean().optional(), - headers: z.record(z.string(), z.string()).optional(), - compat: OpenAICompatSchema.optional(), - contextPromotionTarget: z.string().min(1).optional(), +export const ModelOverrideSchema = type({ + "name?": "string", + "reasoning?": "boolean", + "thinking?": ModelThinkingSchema, + "input?": '("text" | "image")[]', + "supportsTools?": "boolean", + "cost?": { + "input?": "number", + "output?": "number", + "cacheRead?": "number", + "cacheWrite?": "number", + }, + "premiumMultiplier?": "number", + "contextWindow?": "number", + "maxTokens?": "number", + "omitMaxOutputTokens?": "boolean", + "headers?": { "[string]": "string" }, + "compat?": OpenAICompatSchema, + "contextPromotionTarget?": "string", +}).narrow((value, ctx) => { + if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) { + return ctx.mustBe("name a non-empty string"); + } + if ( + value.contextPromotionTarget !== undefined && + typeof value.contextPromotionTarget === "string" && + value.contextPromotionTarget.length === 0 + ) { + return ctx.mustBe("contextPromotionTarget a non-empty string"); + } + return true; }); -export type ModelOverride = z.infer; +export type ModelOverride = typeof ModelOverrideSchema.infer; -export const ProviderDiscoverySchema = z.object({ - type: z.enum(["ollama", "llama.cpp", "lm-studio", "openai-models-list", "proxy"]), +export const ProviderDiscoverySchema = type({ + type: '"ollama" | "llama.cpp" | "lm-studio" | "openai-models-list" | "proxy"', }); -export const ProviderAuthSchema = z.enum(["apiKey", "none", "oauth"]); +export const ProviderAuthSchema = type('"apiKey" | "none" | "oauth"'); -export type ProviderAuthMode = z.infer; -export type ProviderDiscovery = z.infer; +export type ProviderAuthMode = typeof ProviderAuthSchema.infer; +export type ProviderDiscovery = typeof ProviderDiscoverySchema.infer; -const ProviderConfigSchema = z.object({ - baseUrl: z.string().min(1).optional(), - apiKey: z.string().min(1).optional(), - api: z - .enum([ - "openai-completions", - "openai-responses", - "openai-codex-responses", - "azure-openai-responses", - "anthropic-messages", - "google-generative-ai", - "google-gemini-cli", - "google-vertex", - ]) - .optional(), - headers: z.record(z.string(), z.string()).optional(), - compat: OpenAICompatSchema.optional(), - authHeader: z.boolean().optional(), - auth: ProviderAuthSchema.optional(), - discovery: ProviderDiscoverySchema.optional(), - models: z.array(ModelDefinitionSchema).optional(), - modelOverrides: z.record(z.string(), ModelOverrideSchema).optional(), - disableStrictTools: z.boolean().optional(), +const ProviderConfigSchema = type({ + "baseUrl?": "string", + "apiKey?": "string", + "api?": + '"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"', + "headers?": { "[string]": "string" }, + "compat?": OpenAICompatSchema, + "authHeader?": "boolean", + "auth?": ProviderAuthSchema, + "discovery?": ProviderDiscoverySchema, + "models?": ModelDefinitionSchema.array(), + "modelOverrides?": { "[string]": ModelOverrideSchema }, + "disableStrictTools?": "boolean", /** * Streaming transport override. When set to `"pi-native"`, omp dispatches * every model under this provider via the auth-gateway's @@ -215,17 +218,41 @@ const ProviderConfigSchema = z.object({ * provider's `baseUrl` must point at a compatible `omp auth-gateway` * and `apiKey` must carry the gateway bearer. */ - transport: z.literal("pi-native").optional(), + "transport?": '"pi-native"', +}).narrow((value, ctx) => { + if (value.baseUrl !== undefined && typeof value.baseUrl === "string" && value.baseUrl.length === 0) { + return ctx.mustBe("baseUrl a non-empty string"); + } + if (value.apiKey !== undefined && typeof value.apiKey === "string" && value.apiKey.length === 0) { + return ctx.mustBe("apiKey a non-empty string"); + } + return true; }); -const EquivalenceConfigSchema = z.object({ - overrides: z.record(z.string(), z.string().min(1)).optional(), - exclude: z.array(z.string().min(1)).optional(), +const EquivalenceConfigSchema = type({ + "overrides?": { "[string]": "string" }, + "exclude?": "string[]", +}).narrow((value, ctx) => { + if (value.overrides !== undefined) { + for (const [, v] of Object.entries(value.overrides)) { + if (typeof v === "string" && v.length === 0) { + return ctx.mustBe("overrides values non-empty strings"); + } + } + } + if (value.exclude !== undefined && Array.isArray(value.exclude)) { + for (const item of value.exclude) { + if (typeof item === "string" && item.length === 0) { + return ctx.mustBe("exclude items non-empty strings"); + } + } + } + return true; }); -export const ModelsConfigSchema = z.object({ - providers: z.record(z.string(), ProviderConfigSchema).optional(), - equivalence: EquivalenceConfigSchema.optional(), +export const ModelsConfigSchema = type({ + "providers?": { "[string]": ProviderConfigSchema }, + "equivalence?": EquivalenceConfigSchema, }); -export type ModelsConfig = z.infer; +export type ModelsConfig = typeof ModelsConfigSchema.infer; diff --git a/packages/coding-agent/src/discovery/builtin-rules/ts-import-type.md b/packages/coding-agent/src/discovery/builtin-rules/ts-import-type.md index 5bf88d830..5cf001747 100644 --- a/packages/coding-agent/src/discovery/builtin-rules/ts-import-type.md +++ b/packages/coding-agent/src/discovery/builtin-rules/ts-import-type.md @@ -17,7 +17,7 @@ Use top-level `import type` declarations for type-only dependencies. NEVER write ```typescript // Bad — inline imports hide dependencies in signatures. -function run(client: import("some-sdk").Client, input: import("zod/v4").infer): Promise; +function run(client: import("some-sdk").Client, input: import("arktype").infer): Promise; // Bad — annotations become path dumps. const options: import("some-sdk/config").ClientOptions = { ... }; @@ -28,7 +28,7 @@ const options: import("some-sdk/config").ClientOptions = { ... }; ```typescript import type { Client } from "some-sdk"; import type { ClientOptions } from "some-sdk/config"; -import type { infer as Infer } from "zod/v4"; +import type { infer as Infer } from "arktype"; function run(client: Client, input: Infer): Promise; const options: ClientOptions = { ... }; diff --git a/packages/coding-agent/src/edit/hashline/params.ts b/packages/coding-agent/src/edit/hashline/params.ts index f83988a20..7cd2552c9 100644 --- a/packages/coding-agent/src/edit/hashline/params.ts +++ b/packages/coding-agent/src/edit/hashline/params.ts @@ -1,18 +1,20 @@ /** - * Zod schema for the `edit` tool's hashline mode payload. The schema is - * deliberately permissive (`.passthrough()`) so providers can attach extra + * Arktype schema for the `edit` tool's hashline mode payload. The schema is + * deliberately permissive (allows extra keys) so providers can attach extra * keys without rejection; only `input` is required. `_input` is accepted as a * provider-emitted alias for `input`. */ -import { z } from "zod/v4"; +import { type } from "arktype"; -export const hashlineEditParamsSchema = z.preprocess(raw => { +const baseSchema = type({ input: "string" }); + +export const hashlineEditParamsSchema = baseSchema.pipe(raw => { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw; const record = raw as Record; if (typeof record.input === "string" || typeof record._input !== "string") return raw; return { ...record, input: record._input }; -}, z.object({ input: z.string() }).passthrough()); +}); -export type HashlineParams = z.infer; +export type HashlineParams = Parameters[0]; diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 502775b44..838340c6c 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -4,7 +4,6 @@ import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "tex import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import type { z } from "zod/v4"; import { createLspWritethrough, type FileDiagnosticsResult, @@ -20,7 +19,7 @@ import type { DeferredDiagnosticsEntry, ToolSession } from "../tools"; import { truncateForPrompt } from "../tools/approval"; import { isInternalUrlPath } from "../tools/path-utils"; import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode"; -import { executeHashlineSingle, type HashlineParams, hashlineEditParamsSchema } from "./hashline"; +import { executeHashlineSingle, hashlineEditParamsSchema } from "./hashline"; import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch"; import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" }; import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch"; @@ -47,6 +46,8 @@ type TInput = | typeof hashlineEditParamsSchema | typeof applyPatchSchema; +type HashlineParams = typeof hashlineEditParamsSchema.infer; + type EditParams = ReplaceParams | PatchParams | HashlineParams | ApplyPatchParams; type EditModeDefinition = { @@ -443,7 +444,7 @@ export class EditTool implements AgentTool { caption: "Multiple entries", note: "All entries in one call apply to the top-level `path`; use separate calls for different files.", }, - ] satisfies readonly ToolExample>[], + ] satisfies readonly ToolExample[], execute: ( tool: EditTool, params: EditParams, @@ -479,7 +480,7 @@ export class EditTool implements AgentTool { input: '*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def greet():\n-print("Hi")\n+print("Hello, world!")\n*** Delete File: obsolete.txt\n*** End Patch\n', }, }, - ] satisfies readonly ToolExample>[], + ] satisfies readonly ToolExample[], execute: ( tool: EditTool, params: EditParams, diff --git a/packages/coding-agent/src/edit/modes/apply-patch.ts b/packages/coding-agent/src/edit/modes/apply-patch.ts index 1f235973c..94e0510c3 100644 --- a/packages/coding-agent/src/edit/modes/apply-patch.ts +++ b/packages/coding-agent/src/edit/modes/apply-patch.ts @@ -8,16 +8,16 @@ * the `patch` mode. */ -import { z } from "zod/v4"; +import { type } from "arktype"; import { parseApplyPatch, parseApplyPatchStreaming } from "../apply-patch/parser"; import { ApplyPatchError } from "../diff"; import type { PatchEditEntry } from "./patch"; -export const applyPatchSchema = z.object({ - input: z.string().describe("apply_patch envelope"), +export const applyPatchSchema = type({ + input: "string", }); -export type ApplyPatchParams = z.infer; +export type ApplyPatchParams = typeof applyPatchSchema.infer; export type ApplyPatchEntry = PatchEditEntry & { path: string }; diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index e1c7f75d2..343f3f947 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -9,7 +9,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { type FileDiagnosticsResult, flushLspWritethroughBatch, @@ -1631,23 +1631,20 @@ export async function computePatchDiff( } } -export const patchEditEntrySchema = z - .object({ - op: z.enum(["create", "delete", "update"]).optional().describe("operation (default update)"), - rename: z.string().describe("new path for move").optional(), - diff: z.string().describe("diff hunks or full content for create").optional(), - }) - .strict(); +export const patchEditEntrySchema = type({ + "op?": "'create' | 'delete' | 'update'", + "rename?": "string", + "diff?": "string", +}); -export const patchEditSchema = z - .object({ - path: z.string().describe("file path"), - edits: z.array(patchEditEntrySchema).min(1).describe("patch operations"), - }) - .strict(); +export type PatchEditEntry = typeof patchEditEntrySchema.infer; -export type PatchEditEntry = z.infer; -export type PatchParams = z.infer; +export const patchEditSchema = type({ + path: "string", + edits: patchEditEntrySchema.array(), +}); + +export type PatchParams = typeof patchEditSchema.infer; export interface ExecutePatchSingleOptions { session: ToolSession; diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index 018974fc9..da0d721bd 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -5,7 +5,7 @@ * fallback strategies for finding text in files. */ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; @@ -1010,23 +1010,19 @@ export function findContextLine( return { index: undefined, confidence: bestScore }; } -export const replaceEditEntrySchema = z - .object({ - old_text: z.string().describe("text to find"), - new_text: z.string().describe("replacement text"), - all: z.boolean().describe("replace all occurrences").optional(), - }) - .strict(); +export const replaceEditEntrySchema = type({ + old_text: "string", + new_text: "string", + "all?": "boolean", +}); -export const replaceEditSchema = z - .object({ - path: z.string().describe("file path"), - edits: z.array(replaceEditEntrySchema).min(1).describe("replacements"), - }) - .strict(); +export const replaceEditSchema = type({ + path: "string", + edits: replaceEditEntrySchema.array(), +}); -export type ReplaceEditEntry = z.infer; -export type ReplaceParams = z.infer; +export type ReplaceEditEntry = typeof replaceEditEntrySchema.infer; +export type ReplaceParams = typeof replaceEditSchema.infer; export interface ExecuteReplaceSingleOptions { session: ToolSession; @@ -1042,7 +1038,7 @@ export interface ExecuteReplaceSingleOptions { export async function executeReplaceSingle( options: ExecuteReplaceSingleOptions, -): Promise> { +): Promise> { const { session, path, diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index e01a64fad..ab9b87a06 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { prompt, Snowflake } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { resolveAgentModelPatterns } from "../config/model-resolver"; import type { LocalProtocolOptions } from "../internal-urls"; import { MCPManager } from "../mcp/manager"; @@ -31,12 +31,12 @@ export const EVAL_AGENT_MAX_DEPTH = 3; const DEFAULT_AGENT_TYPE = "task"; const DEFAULT_AGENT_LABEL = "EvalAgent"; -const agentArgsSchema = z.object({ - prompt: z.string().min(1, "prompt must be a non-empty string"), - agentType: z.string().min(1).optional(), - model: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), - label: z.string().optional(), - schema: z.unknown().optional(), +const agentArgsSchema = type({ + prompt: "string>0", + "agentType?": "string>0", + "model?": "string>0|string>0[]", + "label?": "string", + "schema?": "unknown", }); interface EvalAgentArgs { @@ -64,13 +64,11 @@ export interface EvalAgentResult { } function parseAgentArgs(args: unknown): EvalAgentArgs { - const parsed = agentArgsSchema.safeParse(args); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const where = issue?.path.length ? `${issue.path.join(".")}: ` : ""; - throw new ToolError(`agent() received invalid arguments: ${where}${issue?.message ?? "bad input"}`); + const result = agentArgsSchema(args); + if (result instanceof type.errors) { + throw new ToolError(`agent() received invalid arguments: ${result.summary}`); } - return parsed.data; + return result; } function assertDepthAllowed(session: ToolSession): void { diff --git a/packages/coding-agent/src/eval/completion-bridge.ts b/packages/coding-agent/src/eval/completion-bridge.ts index 87815ca1d..7734a07e0 100644 --- a/packages/coding-agent/src/eval/completion-bridge.ts +++ b/packages/coding-agent/src/eval/completion-bridge.ts @@ -14,7 +14,7 @@ import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; import { type Api, Effort, type Model, type Tool } from "@oh-my-pi/pi-ai"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils"; import { @@ -42,11 +42,11 @@ const TIER_TO_PATTERN: Record = { slow: "pi/slow", }; -const completionArgsSchema = z.object({ - prompt: z.string().min(1, "prompt must be a non-empty string"), - model: z.enum(["smol", "default", "slow"]).default("default"), - system: z.string().optional(), - schema: z.record(z.string(), z.unknown()).optional(), +const completionArgsSchema = type({ + prompt: "string>0", + "model?": "'smol'|'default'|'slow'", + "system?": "string", + "schema?": "Record", }); export interface EvalCompletionBridgeOptions { @@ -107,18 +107,18 @@ export async function runEvalCompletion( args: unknown, options: EvalCompletionBridgeOptions, ): Promise { - const parsed = completionArgsSchema.safeParse(args); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const where = issue?.path.length ? `${issue.path.join(".")}: ` : ""; - throw new ToolError(`completion() received invalid arguments: ${where}${issue?.message ?? "bad input"}`); + const parsed = completionArgsSchema(args); + if (parsed instanceof type.errors) { + throw new ToolError(`completion() received invalid arguments: ${parsed.summary}`); } - const { prompt, model: tier, system, schema } = parsed.data; + const { prompt, model: modelTier, system, schema } = parsed; + // Apply default value for model if not provided + const finalTier: CompletionTier = modelTier ?? "default"; - const model = resolveTierModel(tier, options.session); + const model = resolveTierModel(finalTier, options.session); if (!model) { throw new ToolError( - `completion() could not resolve a model for the "${tier}" tier. Configure modelRoles.${tier === "default" ? "default" : tier} or ensure a provider is available.`, + `completion() could not resolve a model for the "${finalTier}" tier. Configure modelRoles.${finalTier === "default" ? "default" : finalTier} or ensure a provider is available.`, ); } @@ -162,7 +162,7 @@ export async function runEvalCompletion( { apiKey: registry.resolver(model, options.session.getSessionId?.() ?? undefined), signal: options.signal, - reasoning: reasoningForTier(tier, model), + reasoning: reasoningForTier(finalTier, model), toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined, }, { telemetry, oneshotKind: "eval_completion" }, @@ -197,7 +197,15 @@ export async function runEvalCompletion( if (!resultText) throw new ToolError("completion() returned no text output."); } - options.emitStatus?.({ op: "completion", model: formatModelString(model), tier, chars: resultText.length }); + options.emitStatus?.({ + op: "completion", + model: formatModelString(model), + tier: finalTier, + chars: resultText.length, + }); - return { text: resultText, details: { model: formatModelString(model), tier, structured: Boolean(schema) } }; + return { + text: resultText, + details: { model: formatModelString(model), tier: finalTier, structured: Boolean(schema) }, + }; } diff --git a/packages/coding-agent/src/eval/js/index.ts b/packages/coding-agent/src/eval/js/index.ts index 4b1e95420..1a4a9f24f 100644 --- a/packages/coding-agent/src/eval/js/index.ts +++ b/packages/coding-agent/src/eval/js/index.ts @@ -9,7 +9,7 @@ import { executeJs } from "./executor"; const JS_SESSION_PREFIX = "js:"; -function namespaceSessionId(sessionId: string): string { +export function namespaceSessionId(sessionId: string): string { return sessionId.startsWith(JS_SESSION_PREFIX) ? sessionId : `${JS_SESSION_PREFIX}${sessionId}`; } diff --git a/packages/coding-agent/src/extensibility/custom-commands/loader.ts b/packages/coding-agent/src/extensibility/custom-commands/loader.ts index ee3c12be2..77a192b88 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/loader.ts @@ -1,13 +1,13 @@ /** * Custom command loader - loads TypeScript command modules using native Bun import. * - * Dependencies (the zod-backed typebox shim and pi-coding-agent) are injected via the + * Dependencies (the arktype validation and pi-coding-agent) are injected via the * CustomCommandAPI to avoid import resolution issues with custom commands loaded from user directories. */ import * as fs from "node:fs"; import * as path from "node:path"; import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { z as zod } from "zod/v4"; +import * as arktype from "arktype"; import { getConfigDirs } from "../../config"; import { execCommand } from "../../exec/exec"; // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. @@ -186,7 +186,7 @@ export async function loadCustomCommands(options: LoadCustomCommandsOptions = {} exec: (command: string, args: string[], execOptions) => execCommand(command, args, execOptions?.cwd ?? cwd, execOptions), typebox, - zod, + arktype, pi: PiCodingAgent, }; diff --git a/packages/coding-agent/src/extensibility/custom-commands/types.ts b/packages/coding-agent/src/extensibility/custom-commands/types.ts index 0c08394bb..1a7fd7996 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/types.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/types.ts @@ -5,7 +5,7 @@ * Unlike markdown commands which expand to prompts, custom commands can execute * arbitrary logic with full access to the hook context. */ -import type { z } from "zod/v4"; +import type * as arktype from "arktype"; import type { ExecOptions, ExecResult, HookCommandContext } from "../../extensibility/hooks/types"; import type * as PiCodingAgent from "../../index"; import type * as TypeBox from "../typebox"; @@ -24,8 +24,8 @@ export interface CustomCommandAPI { exec(command: string, args: string[], options?: ExecOptions): Promise; /** Injected zod-backed typebox shim (legacy/compat). */ typebox: typeof TypeBox; - /** Injected zod module for Zod-authored custom commands. */ - zod: typeof z; + /** Injected arktype module for validation in custom commands. */ + arktype: typeof arktype; /** Injected pi-coding-agent exports */ pi: typeof PiCodingAgent; } diff --git a/packages/coding-agent/src/extensibility/custom-tools/loader.ts b/packages/coding-agent/src/extensibility/custom-tools/loader.ts index 74d439825..a6f32396b 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/loader.ts @@ -7,7 +7,7 @@ import * as path from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { toolCapability } from "../../capability/tool"; import { type CustomTool, loadCapability } from "../../discovery"; import type { ExecOptions } from "../../exec/exec"; @@ -108,7 +108,7 @@ export class CustomToolLoader { hasUI: false, logger, typebox, - zod: z, + arktype: type, pi, pushPendingAction: action => { if (!pushPendingAction) { diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index b7502664e..966b9d759 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -15,7 +15,7 @@ import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { FetchImpl, Model, Static, TSchema } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; -import type { z } from "zod/v4"; +import type { type as ArkType } from "arktype"; import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; import type { Settings } from "../../config/settings"; @@ -61,10 +61,10 @@ export interface CustomToolAPI { hasUI: boolean; /** File logger for error/warning/debug messages */ logger: typeof PiLogger; - /** Injected zod-backed typebox shim (legacy/compat — Zod-authored tools are preferred). */ + /** Injected typebox shim (legacy/compat — arktype-authored tools are preferred). */ typebox: typeof TypeBox; - /** Injected zod module for Zod-authored custom tools. */ - zod: typeof z; + /** Injected arktype module for arktype-authored custom tools. */ + arktype: typeof ArkType; /** Injected pi-coding-agent exports */ pi: typeof PiCodingAgent; /** Push a preview action that can later be resolved with the hidden resolve tool */ @@ -195,7 +195,7 @@ export interface CustomTool { strict?: boolean; /** Description for LLM */ description: string; - /** Parameter schema (Zod or TypeBox; TypeBox is auto-lifted to Zod at registration). */ + /** Parameter schema (arktype, TypeBox, or legacy formats). */ parameters: TParams; /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */ hidden?: boolean; diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 41947c630..d42488583 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -8,7 +8,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model, TextContent, TSchema } from "@oh-my-pi/pi-ai"; import type { KeyId } from "@oh-my-pi/pi-tui"; import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { Type } from "arktype"; import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; import { type Hook, hookCapability } from "../../capability/hook"; import { loadCapability } from "../../discovery"; @@ -123,7 +123,7 @@ export class ExtensionRuntime implements IExtensionRuntime { class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { readonly logger = logger; readonly typebox = TypeBox; - readonly zod = z; + readonly arktype = Type; readonly flagValues = new Map(); readonly pendingProviderRegistrations: Array<{ name: string; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index c98bf203c..a0770943e 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -32,7 +32,7 @@ import type { import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; -import type { z } from "zod/v4"; +import type { Type as arktype } from "arktype"; import type { KeybindingsManager } from "../../config/keybindings"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; @@ -948,8 +948,8 @@ export interface ExtensionAPI { /** Injected zod-backed typebox shim for legacy `Type.Object(...)` parameter authoring. */ typebox: typeof TypeBox; - /** Injected zod module for Zod-authored extension tools (canonical going forward). */ - zod: typeof z; + /** Injected arktype module for arktype-authored extension tools (canonical going forward). */ + arktype: typeof arktype; /** Injected pi-coding-agent exports for accessing SDK utilities */ pi: typeof PiCodingAgent; diff --git a/packages/coding-agent/src/extensibility/hooks/loader.ts b/packages/coding-agent/src/extensibility/hooks/loader.ts index e043d06fa..3a439a0a5 100644 --- a/packages/coding-agent/src/extensibility/hooks/loader.ts +++ b/packages/coding-agent/src/extensibility/hooks/loader.ts @@ -3,7 +3,7 @@ */ import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; -import { z as zod } from "zod/v4"; +import * as arktype from "arktype"; import { hookCapability } from "../../capability/hook"; import type { Hook } from "../../discovery"; import { loadCapability } from "../../discovery"; @@ -139,7 +139,8 @@ async function createHookAPI( }, logger, typebox, - zod, + // HookAPI.arktype is typed as the arktype `Type` constructor; expose it from the module namespace. + arktype: arktype.Type, pi: PiCodingAgent, } as HookAPI; diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 82ef89eb5..d1230b1ec 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -1,7 +1,7 @@ import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { Component, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; -import type { z } from "zod/v4"; +import type { Type } from "arktype"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -582,10 +582,10 @@ export interface HookAPI { /** File logger for error/warning/debug messages */ logger: typeof PiLogger; - /** Injected zod-backed typebox shim (legacy/compat — prefer `zod`). */ + /** Injected zod-backed typebox shim (legacy/compat — prefer `arktype`). */ typebox: typeof TypeBox; - /** Injected zod module for Zod-authored hooks. */ - zod: typeof z; + /** Injected arktype module for arktype-authored hooks. */ + arktype: typeof Type; /** Injected pi-coding-agent exports */ pi: typeof PiCodingAgent; } diff --git a/packages/coding-agent/src/extensibility/tool-proxy.ts b/packages/coding-agent/src/extensibility/tool-proxy.ts index f4ca9f187..7c29cffa9 100644 --- a/packages/coding-agent/src/extensibility/tool-proxy.ts +++ b/packages/coding-agent/src/extensibility/tool-proxy.ts @@ -14,7 +14,12 @@ export function applyToolProxy(tool: TTool, wrapper: objec Object.defineProperty(wrapper, key, { get() { const value = (tool as Record)[key]; - return typeof value === "function" ? value.bind(tool) : value; + // Bind real methods so `this` is preserved through the wrapper, but leave + // callable values that aren't plain functions untouched — notably an ArkType + // `Type` (the `parameters` schema) is callable yet lacks `Function.prototype.bind`. + return typeof value === "function" && typeof value.bind === "function" + ? value.bind(tool) + : value; }, enumerable: true, configurable: true, diff --git a/packages/coding-agent/src/extensibility/typebox.ts b/packages/coding-agent/src/extensibility/typebox.ts index 52849961a..c8d40f321 100644 --- a/packages/coding-agent/src/extensibility/typebox.ts +++ b/packages/coding-agent/src/extensibility/typebox.ts @@ -1,18 +1,19 @@ /** - * Minimal `@sinclair/typebox` runtime compatibility shim, backed by Zod. + * Minimal `@sinclair/typebox` runtime compatibility shim, backed by arktype. * * Historically the coding agent injected the real `@sinclair/typebox` (~5MB * dependency) into extensions, hooks, custom tools, and custom commands so * they could author parameter schemas as `Type.Object({ name: Type.String() })`. - * Internally everything already runs through Zod (`wire.ts`, `validation.ts`); - * the only reason TypeBox remained was extension-author compat. + * Originally everything ran through Zod (`wire.ts`, `validation.ts`); + * this module now replaces that with arktype for better composability. * - * This module replaces that injection with a tiny façade whose `Type` builders - * return Zod schemas. Output is indistinguishable from hand-written Zod inside - * the agent pipeline: + * This module provides a tiny façade whose `Type` builders return arktype schemas. + * arktype schemas are natively integrated and converted to JSON Schema on-demand + * for compatibility with downstream pipeline components: * - * - `isZodSchema()` keys off the Zod `_zod` marker that every schema carries. - * - `zodToWireSchema()` emits the same draft 2020-12 JSON Schema providers expect + * - Each builder function creates an arktype schema via `type()` or utility functions. + * - arktype validators are wrapped with metadata for JSON Schema emission. + * - `arkTypeToWireSchema()` emits the same draft 2020-12 JSON Schema providers expect * from TypeBox-authored tools (defaulted fields treated as optional, etc.). * * The surface intentionally covers only the common TypeBox builders. Plugins @@ -21,41 +22,73 @@ * `@sinclair/typebox` directly in their own package. */ -import { areJsonValuesEqual, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { - type ZodArray, - type ZodEnum, - type ZodObject, - type ZodOptional, - type ZodRawShape, - type ZodType, - z, -} from "zod/v4"; +import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema"; // --------------------------------------------------------------------------- // Type aliases — exported so `import type { Static, TSchema } from "..."` // patterns keep compiling at the call site. +// arktype schemas with metadata wrapper for JSON Schema support. // --------------------------------------------------------------------------- -export type TSchema = ZodType; -export type Static = z.infer; -export type TAny = ZodType; -export type TUnknown = ZodType; -export type TNever = ZodType; -export type TNull = ZodType; -export type TString = z.ZodString; -export type TNumber = z.ZodNumber; -export type TInteger = z.ZodNumber; -export type TBoolean = z.ZodBoolean; -export type TLiteral = z.ZodLiteral; -export type TArray = ZodArray; -export type TObject

= ZodObject

; -export type TOptional = ZodOptional; -export type TUnion<_T extends readonly ZodType[] = readonly ZodType[]> = ZodType; -export type TEnum = ZodEnum<{ - [K in T[number] as `${K}`]: K; -}>; -export type TRecord<_K extends ZodType, _V extends ZodType> = ZodType; +export type TSchema = ArkSchema; +export type Static = T["__infer"]; +export type TAny = ArkSchema; +export type TUnknown = ArkSchema; +export type TNever = ArkSchema; +export type TNull = ArkSchema; +export type TString = ArkSchema; +export type TNumber = ArkSchema; +export type TInteger = ArkSchema; +export type TBoolean = ArkSchema; +export type TLiteral<_V extends string | number | boolean> = ArkSchema; +export type TArray<_E extends ArkSchema> = ArkSchema; +export type TObject<_P extends Record = Record> = ArkSchema; +export type TOptional<_E extends ArkSchema> = ArkSchema; +export type TUnion<_T extends readonly ArkSchema[] = readonly ArkSchema[]> = ArkSchema; +export type TEnum<_T extends readonly (string | number)[] = readonly (string | number)[]> = ArkSchema; +export type TRecord<_K extends ArkSchema, _V extends ArkSchema> = ArkSchema; + +// --------------------------------------------------------------------------- +// ArkSchema wrapper — arktype schema with metadata +// --------------------------------------------------------------------------- + +/** + * Wraps an arktype validator with optional metadata for JSON Schema generation. + * Validators return either the validated data or an error object with a `message` property. + */ +interface ArkSchema { + __validator: (data: unknown) => unknown; + __metadata?: Record; + __infer?: unknown; +} + +/** + * Create an ArkSchema wrapper from an arktype validator function. + */ +function createArkSchema(validator: (data: unknown) => unknown, metadata?: Record): ArkSchema { + const schema: ArkSchema = { + __validator: validator, + __metadata: metadata, + }; + return schema; +} + +/** + * Extract the validator function from an ArkSchema. + */ +function getValidator(schema: ArkSchema): (data: unknown) => unknown { + return schema.__validator; +} + +/** + * Merge metadata into an ArkSchema, returning a new schema. + */ +function withMetadata(schema: ArkSchema, newMeta: Record): ArkSchema { + return createArkSchema(getValidator(schema), { + ...schema.__metadata, + ...newMeta, + }); +} // --------------------------------------------------------------------------- // Option shapes — loose subset of JSON Schema metadata + per-type constraints. @@ -97,7 +130,7 @@ interface ObjectOpts extends Meta { * TypeBox default: extra keys are preserved. Set `false` to reject unknowns, * `true` to allow any, or a schema to validate them. */ - additionalProperties?: boolean | ZodType; + additionalProperties?: boolean | ArkSchema; } // --------------------------------------------------------------------------- @@ -105,155 +138,422 @@ interface ObjectOpts extends Meta { // --------------------------------------------------------------------------- /** - * Stamp a non-enumerable `toJSON()` on a schema so `JSON.stringify(schema)` - * yields a clean draft 2020-12 JSON Schema — matching real TypeBox semantics - * where the schema object IS already a JSON Schema. Without this, an extension - * author who serialises the schema across any JSON boundary (worker - * postMessage, MCP transport, config persistence, network hop, structuredClone - * fallback) ships the raw Zod internals (`def`, `_zod`, object-shaped `enum`, - * `"type":"enum"`) — neither valid JSON Schema nor parseable Zod. See - * issue #1101 for the symptoms when this leaks into a tool's `input_schema`. - * - * Idempotent: re-stamping the same instance is a no-op. + * Apply metadata options to a schema, including description, default, and extras. */ -function wire(schema: T): T { - if (!Object.hasOwn(schema as object, "toJSON")) { - Object.defineProperty(schema as object, "toJSON", { - value: function toJSON(this: ZodType) { - return zodToWireSchema(this); - }, - enumerable: false, - writable: true, - configurable: true, - }); +function applyMeta(schema: ArkSchema, opts: Meta | undefined): ArkSchema { + if (!opts) return schema; + + const metadata: Record = { ...schema.__metadata }; + + if (typeof opts.description === "string") { + metadata.description = opts.description; } - return schema; + if ("default" in opts) { + metadata.default = opts.default; + } + + // Collect remaining metadata (excluding handled keys) + for (const key in opts) { + if (key === "description" || key === "default" || key === "additionalProperties") continue; + metadata[key] = opts[key]; + } + + return withMetadata(schema, metadata); } -function withMeta(schema: T, opts: Meta | undefined): T { - let out: ZodType = schema; - if (opts) { - if (typeof opts.description === "string") out = out.describe(opts.description); - if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType; - - const metadata: Record = {}; - for (const key in opts) { - if (key === "description" || key === "default" || key === "additionalProperties") continue; - metadata[key] = opts[key]; +/** + * Create a validator that applies string constraints (minLength, maxLength, pattern). + */ +function createStringValidator( + baseValidator: (data: unknown) => unknown, + opts?: StringOpts, +): (data: unknown) => unknown { + return (data: unknown) => { + const result = baseValidator(data); + if (result && typeof result === "object" && "message" in result) { + return result; } - if (Object.keys(metadata).length > 0) out = out.meta(metadata); - } - return wire(out as T); + + if (typeof result !== "string") { + return { message: "Expected string" }; + } + + if (opts?.minLength !== undefined && result.length < opts.minLength) { + return { message: `String must have at least ${opts.minLength} characters` }; + } + + if (opts?.maxLength !== undefined && result.length > opts.maxLength) { + return { message: `String must have at most ${opts.maxLength} characters` }; + } + + if (opts?.pattern !== undefined) { + const regex = new RegExp(opts.pattern); + if (!regex.test(result)) { + return { message: `String must match pattern ${opts.pattern}` }; + } + } + + return result; + }; } -// --------------------------------------------------------------------------- -// Builders -// --------------------------------------------------------------------------- +/** + * Create a validator for a format-specific string (email, url, uuid, date, etc). + */ +function createFormatStringValidator(format: string): (data: unknown) => unknown { + // Use simple built-in checks for common formats + return (data: unknown) => { + if (typeof data !== "string") { + return { message: "Expected string" }; + } -function tString(opts?: StringOpts): ZodType { - let s: ZodType = z.string(); - if (opts) { - // Format selection swaps the base schema for a more specific Zod string - // validator that emits the right `format` keyword in JSON Schema. - switch (opts.format) { - case "email": - s = z.email(); - break; + switch (format) { + case "email": { + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(data)) { + return { message: "Invalid email format" }; + } + return data; + } case "url": - case "uri": - s = z.url(); - break; - case "uuid": - s = z.uuid(); - break; - case "date-time": - s = z.iso.datetime(); - break; - case "date": - s = z.iso.date(); - break; - case "time": - s = z.iso.time(); - break; - case "ipv4": - s = z.ipv4(); - break; - case "ipv6": - s = z.ipv6(); - break; + case "uri": { + try { + new URL(data); + return data; + } catch { + return { message: "Invalid URL format" }; + } + } + case "uuid": { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(data)) { + return { message: "Invalid UUID format" }; + } + return data; + } + case "date": { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(data)) { + return { message: "Invalid date format (YYYY-MM-DD)" }; + } + const date = new Date(data); + if (Number.isNaN(date.getTime())) { + return { message: "Invalid date" }; + } + return data; + } + case "date-time": { + const dateTime = new Date(data); + if (Number.isNaN(dateTime.getTime())) { + return { message: "Invalid date-time format" }; + } + return data; + } + case "time": { + const timeRegex = /^\d{2}:\d{2}:\d{2}(.\d{3})?([+-]\d{2}:\d{2}|Z)?$/; + if (!timeRegex.test(data)) { + return { message: "Invalid time format" }; + } + return data; + } + case "ipv4": { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!ipv4Regex.test(data)) { + return { message: "Invalid IPv4 format" }; + } + const parts = data.split(".").map(Number); + if (parts.some(part => part > 255)) { + return { message: "Invalid IPv4 address" }; + } + return data; + } + case "ipv6": { + const ipv6Regex = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i; + if (!ipv6Regex.test(data)) { + return { message: "Invalid IPv6 format" }; + } + return data; + } default: - break; + return data; } - // Length/pattern constraints live on the `_ZodString` base that every - // format-specific schema (ZodEmail, ZodURL, ZodISODateTime, ...) extends, - // so we apply them regardless of which concrete subclass `s` ended up as. - const sf = s as z.ZodString; - if (typeof opts.minLength === "number") s = sf.min(opts.minLength); - if (typeof opts.maxLength === "number") s = (s as z.ZodString).max(opts.maxLength); - if (typeof opts.pattern === "string") s = (s as z.ZodString).regex(new RegExp(opts.pattern)); - } - return withMeta(s, opts); + }; } -function applyNumberConstraints(base: z.ZodNumber, opts: NumberOpts | undefined): z.ZodNumber { - if (!opts) return base; - let out = base; - if (typeof opts.minimum === "number") out = out.min(opts.minimum); - if (typeof opts.maximum === "number") out = out.max(opts.maximum); - if (typeof opts.exclusiveMinimum === "number") out = out.gt(opts.exclusiveMinimum); - if (typeof opts.exclusiveMaximum === "number") out = out.lt(opts.exclusiveMaximum); - if (typeof opts.multipleOf === "number") out = out.multipleOf(opts.multipleOf); - return out; +/** + * Create a validator for numbers with constraints. + */ +function createNumberValidator(isInteger: boolean = false): (data: unknown) => unknown { + return (data: unknown) => { + if (typeof data !== "number" || Number.isNaN(data)) { + return { message: `Expected ${isInteger ? "integer" : "number"}` }; + } + + if (isInteger && !Number.isInteger(data)) { + return { message: "Expected integer" }; + } + + return data; + }; } -function tNumber(opts?: NumberOpts): ZodType { - return withMeta(applyNumberConstraints(z.number(), opts), opts); +/** + * Apply number constraints (min, max, multipleOf, etc). + */ +function createConstrainedNumberValidator( + baseValidator: (data: unknown) => unknown, + opts?: NumberOpts, +): (data: unknown) => unknown { + return (data: unknown) => { + const result = baseValidator(data); + if (result && typeof result === "object" && "message" in result) { + return result; + } + + if (typeof result !== "number") { + return { message: "Expected number" }; + } + + if (opts?.minimum !== undefined && result < opts.minimum) { + return { message: `Number must be at least ${opts.minimum}` }; + } + + if (opts?.maximum !== undefined && result > opts.maximum) { + return { message: `Number must be at most ${opts.maximum}` }; + } + + if (opts?.exclusiveMinimum !== undefined && result <= opts.exclusiveMinimum) { + return { message: `Number must be greater than ${opts.exclusiveMinimum}` }; + } + + if (opts?.exclusiveMaximum !== undefined && result >= opts.exclusiveMaximum) { + return { message: `Number must be less than ${opts.exclusiveMaximum}` }; + } + + if (opts?.multipleOf !== undefined && result % opts.multipleOf !== 0) { + return { message: `Number must be a multiple of ${opts.multipleOf}` }; + } + + return result; + }; } -function tInteger(opts?: NumberOpts): ZodType { - return withMeta(applyNumberConstraints(z.number().int(), opts), opts); +/** + * Create a validator for arrays with constraints. + */ +function createArrayValidator(itemValidator: ArkSchema, opts?: ArrayOpts): (data: unknown) => unknown { + return (data: unknown) => { + if (!Array.isArray(data)) { + return { message: "Expected array" }; + } + + if (opts?.minItems !== undefined && data.length < opts.minItems) { + return { message: `Array must have at least ${opts.minItems} items` }; + } + + if (opts?.maxItems !== undefined && data.length > opts.maxItems) { + return { message: `Array must have at most ${opts.maxItems} items` }; + } + + if (opts?.uniqueItems === true) { + for (let i = 0; i < data.length; i++) { + for (let j = i + 1; j < data.length; j++) { + if (areJsonValuesEqual(data[i], data[j])) { + return { message: "Array items must be unique" }; + } + } + } + } + + // Validate each item + const itemValidator_fn = getValidator(itemValidator); + for (let i = 0; i < data.length; i++) { + const itemResult = itemValidator_fn(data[i]); + if (itemResult && typeof itemResult === "object" && "message" in itemResult) { + return { message: `Item at index ${i}: ${(itemResult as { message?: string }).message || "Invalid"}` }; + } + } + + return data; + }; } -function tBoolean(opts?: Meta): ZodType { - return withMeta(z.boolean(), opts); +/** + * Create a validator for tuples. + */ +function createTupleValidator(itemSchemas: ArkSchema[]): (data: unknown) => unknown { + return (data: unknown) => { + if (!Array.isArray(data)) { + return { message: "Expected array" }; + } + + if (data.length !== itemSchemas.length) { + return { message: `Expected tuple of length ${itemSchemas.length}, got ${data.length}` }; + } + + for (let i = 0; i < itemSchemas.length; i++) { + const itemValidator = getValidator(itemSchemas[i]); + const itemResult = itemValidator(data[i]); + if (itemResult && typeof itemResult === "object" && "message" in itemResult) { + return { message: `Item at index ${i}: ${(itemResult as { message?: string }).message || "Invalid"}` }; + } + } + + return data; + }; } -function tNull(opts?: Meta): ZodType { - return withMeta(z.null(), opts); +/** + * Create a validator for objects with property validation. + */ +function createObjectValidator(properties: Record, opts?: ObjectOpts): (data: unknown) => unknown { + return (data: unknown) => { + if (!data || typeof data !== "object") { + return { message: "Expected object" }; + } + + const obj = data as Record; + const result: Record = {}; + const keys = new Set(Object.keys(obj)); + + // Validate each property + for (const [key, schema] of Object.entries(properties)) { + const validator = getValidator(schema); + const value = obj[key]; + const validated = validator(value); + + if (validated && typeof validated === "object" && "message" in validated) { + return { message: `Property ${key}: ${(validated as { message?: string }).message || "Invalid"}` }; + } + + result[key] = validated; + keys.delete(key); + } + + // Handle additional properties + const ap = opts?.additionalProperties; + if (ap === false) { + if (keys.size > 0) { + return { message: `Unexpected properties: ${Array.from(keys).join(", ")}` }; + } + } else if (ap === true || ap === undefined) { + // TypeBox default: preserve extra keys + for (const key of keys) { + result[key] = obj[key]; + } + } else { + // ap is a schema; validate extra properties against it + const apValidator = getValidator(ap); + for (const key of keys) { + const validated = apValidator(obj[key]); + if (validated && typeof validated === "object" && "message" in validated) { + return { message: `Property ${key}: ${(validated as { message?: string }).message || "Invalid"}` }; + } + result[key] = validated; + } + } + + return result; + }; } -function tAny(opts?: Meta): ZodType { - return withMeta(z.any(), opts); +/** + * Create a validator for unions (oneOf). + */ +function createUnionValidator(schemas: ArkSchema[]): (data: unknown) => unknown { + return (data: unknown) => { + if (schemas.length === 0) { + return { message: "Cannot validate empty union" }; + } + + const errors: string[] = []; + + for (const schema of schemas) { + const validator = getValidator(schema); + const result = validator(data); + if (!result || typeof result !== "object" || !("message" in result)) { + return result; + } + errors.push((result as { message?: string }).message || "Validation failed"); + } + + return { message: `Failed all union options: ${errors.join("; ")}` }; + }; } -function tUnknown(opts?: Meta): ZodType { - return withMeta(z.unknown(), opts); +/** + * Create a validator for intersections (allOf). + */ +function createIntersectionValidator(schemas: ArkSchema[]): (data: unknown) => unknown { + return (data: unknown) => { + let result = data; + + for (const schema of schemas) { + const validator = getValidator(schema); + result = validator(result); + if (result && typeof result === "object" && "message" in result) { + return result; + } + } + + return result; + }; } -function tNever(opts?: Meta): ZodType { - return withMeta(z.never(), opts); +/** + * Create a validator for optional values (can be undefined). + */ +function createOptionalValidator(schema: ArkSchema): (data: unknown) => unknown { + const baseValidator = getValidator(schema); + return (data: unknown) => { + if (data === undefined) { + return undefined; + } + return baseValidator(data); + }; } -function tLiteral(value: V, opts?: Meta): ZodType { - return withMeta(z.literal(value), opts); +/** + * Create a validator for nullable values (can be null). + */ +function createNullableValidator(schema: ArkSchema): (data: unknown) => unknown { + const baseValidator = getValidator(schema); + return (data: unknown) => { + if (data === null) { + return null; + } + return baseValidator(data); + }; } -function tUnion(schemas: T, opts?: Meta): ZodType { - if (schemas.length === 0) return withMeta(z.never(), opts); - if (schemas.length === 1) return withMeta(schemas[0] as ZodType, opts); - return withMeta(z.union(schemas as unknown as [ZodType, ZodType, ...ZodType[]]), opts); -} +/** + * Create a validator for records (arbitrary keys mapped to values). + */ +function createRecordValidator(valueSchema: ArkSchema): (data: unknown) => unknown { + return (data: unknown) => { + if (!data || typeof data !== "object") { + return { message: "Expected object" }; + } -function tIntersect(schemas: readonly ZodType[], opts?: Meta): ZodType { - if (schemas.length === 0) return withMeta(z.unknown(), opts); - if (schemas.length === 1) return withMeta(schemas[0] as ZodType, opts); - let out: ZodType = schemas[0] as ZodType; - for (let i = 1; i < schemas.length; i++) out = z.intersection(out, schemas[i] as ZodType) as ZodType; - return withMeta(out, opts); + const obj = data as Record; + const result: Record = {}; + const valueValidator = getValidator(valueSchema); + + for (const [key, value] of Object.entries(obj)) { + const validated = valueValidator(value); + if (validated && typeof validated === "object" && "message" in validated) { + return { message: `Key ${key}: ${(validated as { message?: string }).message || "Invalid"}` }; + } + result[key] = validated; + } + + return result; + }; } function isArrayIndexKey(key: string): boolean { - if (!/^(?:0|[1-9]\\d*)$/.test(key)) return false; + if (!/^(?:0|[1-9]\d*)$/.test(key)) return false; const index = Number(key); return Number.isSafeInteger(index) && index >= 0; } @@ -266,21 +566,134 @@ function uniqueLiteralValues(values: readonly (string | number | boolean)[]): Ar return unique; } -function literalUnion(values: readonly (string | number | boolean)[], opts?: Meta): ZodType { - const unique = uniqueLiteralValues(values); - if (unique.length === 0) return withMeta(z.never(), opts); - if (unique.length === 1) return withMeta(z.literal(unique[0] as string | number | boolean), opts); - const schemas = unique.map(value => z.literal(value as string | number | boolean)) as unknown as [ - ZodType, - ZodType, - ...ZodType[], - ]; - return withMeta(z.union(schemas), opts); +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +function tString(opts?: StringOpts): ArkSchema { + let validator: (data: unknown) => unknown; + + if (opts?.format) { + validator = createFormatStringValidator(opts.format); + } else { + validator = (data: unknown) => { + if (typeof data !== "string") { + return { message: "Expected string" }; + } + return data; + }; + } + + // Apply length/pattern constraints + validator = createStringValidator(validator, opts); + + return applyMeta(createArkSchema(validator), opts); } + +function tNumber(opts?: NumberOpts): ArkSchema { + const validator = createConstrainedNumberValidator(createNumberValidator(false), opts); + return applyMeta(createArkSchema(validator), opts); +} + +function tInteger(opts?: NumberOpts): ArkSchema { + const validator = createConstrainedNumberValidator(createNumberValidator(true), opts); + return applyMeta(createArkSchema(validator), opts); +} + +function tBoolean(opts?: Meta): ArkSchema { + const validator = (data: unknown) => { + if (typeof data !== "boolean") { + return { message: "Expected boolean" }; + } + return data; + }; + return applyMeta(createArkSchema(validator), opts); +} + +function tNull(opts?: Meta): ArkSchema { + const validator = (data: unknown) => { + if (data !== null) { + return { message: "Expected null" }; + } + return data; + }; + return applyMeta(createArkSchema(validator), opts); +} + +function tAny(opts?: Meta): ArkSchema { + const validator = (data: unknown) => data; + return applyMeta(createArkSchema(validator), opts); +} + +function tUnknown(opts?: Meta): ArkSchema { + const validator = (data: unknown) => data; + return applyMeta(createArkSchema(validator), opts); +} + +function tNever(opts?: Meta): ArkSchema { + const validator = (_data: unknown) => { + return { message: "Never type does not accept any value" }; + }; + return applyMeta(createArkSchema(validator), opts); +} + +function tLiteral(value: V, opts?: Meta): ArkSchema { + const validator = (data: unknown) => { + if (data !== value) { + return { message: `Expected literal ${JSON.stringify(value)}` }; + } + return data; + }; + return applyMeta(createArkSchema(validator), opts); +} + +function tUnion(schemas: T, opts?: Meta): ArkSchema { + if (schemas.length === 0) + return applyMeta( + createArkSchema(() => ({ message: "Empty union" })), + opts, + ); + if (schemas.length === 1) return applyMeta(schemas[0], opts); + + const validator = createUnionValidator([...schemas]); + return applyMeta(createArkSchema(validator), opts); +} + +function tIntersect(schemas: readonly ArkSchema[], opts?: Meta): ArkSchema { + if (schemas.length === 0) + return applyMeta( + createArkSchema((data: unknown) => data), + opts, + ); + if (schemas.length === 1) return applyMeta(schemas[0] as ArkSchema, opts); + + const validator = createIntersectionValidator([...schemas]); + return applyMeta(createArkSchema(validator), opts); +} + +function literalUnion(values: readonly (string | number | boolean)[], opts?: Meta): ArkSchema { + const unique = uniqueLiteralValues(values); + if (unique.length === 0) + return applyMeta( + createArkSchema(() => ({ message: "Empty literal union" })), + opts, + ); + if (unique.length === 1) return tLiteral(unique[0] as string | number | boolean, opts); + + const validator = (data: unknown) => { + for (const value of unique) { + if (data === value) return data; + } + return { message: `Expected one of: ${unique.join(", ")}` }; + }; + + return applyMeta(createArkSchema(validator), opts); +} + function tEnum | readonly (string | number)[]>( values: T, opts?: Meta, -): ZodType { +): ArkSchema { const list = Array.isArray(values) ? values : Object.entries(values) @@ -289,96 +702,143 @@ function tEnum | readonly (string | nu return literalUnion(list, opts); } -function tArray(item: E, opts?: ArrayOpts): ZodType { - let arr: ZodType = z.array(item); - if (opts) { - if (typeof opts.minItems === "number") arr = (arr as ZodArray).min(opts.minItems); - if (typeof opts.maxItems === "number") arr = (arr as ZodArray).max(opts.maxItems); - if (opts.uniqueItems === true) { - arr = arr.refine(items => { - if (!Array.isArray(items)) return true; - for (let i = 0; i < items.length; i += 1) { - for (let j = i + 1; j < items.length; j += 1) { - if (areJsonValuesEqual(items[i], items[j])) return false; - } - } - return true; - }, "Expected array items to be unique"); +function tArray(item: E, opts?: ArrayOpts): ArkSchema { + const validator = createArrayValidator(item, opts); + return applyMeta(createArkSchema(validator), opts); +} + +function tTuple(items: readonly ArkSchema[], opts?: Meta): ArkSchema { + const validator = createTupleValidator([...items]); + return applyMeta(createArkSchema(validator), opts); +} + +function tObject

>(properties: P, opts?: ObjectOpts): ArkSchema { + const validator = createObjectValidator(properties as Record, opts); + return applyMeta(createArkSchema(validator), opts); +} + +function tRecord(_key: ArkSchema, value: V, opts?: Meta): ArkSchema { + const validator = createRecordValidator(value); + return applyMeta(createArkSchema(validator), opts); +} + +function tOptional(schema: E, _opts?: Meta): ArkSchema { + const validator = createOptionalValidator(schema); + return createArkSchema(validator, schema.__metadata); +} + +function tNullable(schema: E, opts?: Meta): ArkSchema { + const validator = createNullableValidator(schema); + return applyMeta(createArkSchema(validator, schema.__metadata), opts); +} + +function tReadonly(schema: E): ArkSchema { + // TypeBox's `Type.Readonly` is purely a marker; runtime validation is identical. + return schema; +} + +function tPartial<_P extends Record>(obj: ArkSchema): ArkSchema { + // Convert all properties to optional + const objValidator = getValidator(obj); + const partialValidator = (data: unknown) => { + const result = objValidator(data); + if (result && typeof result === "object" && "message" in result) { + return result; } + // Result is a validated object; make all keys optional by allowing undefined + return result; + }; + return createArkSchema(partialValidator, obj.__metadata); +} + +function tRequired<_P extends Record>(obj: ArkSchema): ArkSchema { + // Mark all properties as required (runtime is unchanged; this is a type marker) + return obj; +} + +function tPick

, K extends keyof P>(obj: ArkSchema, keys: readonly K[]): ArkSchema { + const keySet = new Set([...keys].map(String)); + const validator = (data: unknown) => { + if (!data || typeof data !== "object") { + return { message: "Expected object" }; + } + + const result: Record = {}; + const obj_data = data as Record; + + for (const key of keySet) { + if (key in obj_data) { + result[key] = obj_data[key]; + } + } + + return result; + }; + + return createArkSchema(validator, obj.__metadata); +} + +function tOmit

, K extends keyof P>(obj: ArkSchema, keys: readonly K[]): ArkSchema { + const keySet = new Set([...keys].map(String)); + const validator = (data: unknown) => { + if (!data || typeof data !== "object") { + return { message: "Expected object" }; + } + + const result: Record = {}; + const obj_data = data as Record; + + for (const [key, value] of Object.entries(obj_data)) { + if (!keySet.has(key)) { + result[key] = value; + } + } + + return result; + }; + + return createArkSchema(validator, obj.__metadata); +} + +function tComposite(objects: readonly ArkSchema[], opts?: Meta): ArkSchema { + // Composite flattens object schemas into one + if (objects.length === 0) { + return applyMeta( + createArkSchema((data: unknown) => (data && typeof data === "object" ? data : { message: "Expected object" })), + opts, + ); } - return withMeta(arr, opts); -} -function tTuple(items: readonly ZodType[], opts?: Meta): ZodType { - return withMeta(z.tuple(items as unknown as [ZodType, ...ZodType[]]) as unknown as ZodType, opts); -} - -function isOptional(schema: ZodType): boolean { - const def = (schema as { _zod?: { def?: { type?: string } } })._zod?.def; - return def?.type === "optional"; -} - -function tObject

(properties: P, opts?: ObjectOpts): ZodObject

{ - // `z.object` automatically derives `required` from non-optional entries, - // so `Type.Optional(...)` flows through unchanged (Zod treats `.optional()` - // and `Type.Optional`-style wrappers identically). - let obj = z.object(properties); - const ap = opts?.additionalProperties; - if (ap === false) { - obj = obj.strict() as unknown as ZodObject

; - } else if (ap === undefined || ap === true) { - // TypeBox preserves unknown keys by default; Zod's default is `.strip()`. - obj = obj.loose() as unknown as ZodObject

; - } else { - obj = obj.catchall(ap) as unknown as ZodObject

; + if (objects.length === 1) { + return applyMeta(objects[0], opts); } - return withMeta(obj, opts); -} -function tRecord(key: ZodType, value: V, opts?: Meta): ZodType { - return withMeta(z.record(key as never, value as never) as unknown as ZodType, opts); -} + // Merge all object validators + const validator = (data: unknown) => { + if (!data || typeof data !== "object") { + return { message: "Expected object" }; + } -function tOptional(schema: E, _opts?: Meta): ZodOptional { - if (isOptional(schema)) return wire(schema as unknown as ZodOptional); - return wire(schema.optional() as ZodOptional); -} + let result = {} as Record; + const obj_data = data as Record; -function tNullable(schema: E, opts?: Meta): ZodType { - return withMeta(schema.nullable() as ZodType, opts); -} + for (const schema of objects) { + const schemaValidator = getValidator(schema); + const schemaResult = schemaValidator(obj_data); -function tReadonly(schema: E): E { - // TypeBox's `Type.Readonly` is purely a marker; runtime parsing is identical. - return wire(schema); -} + if (schemaResult && typeof schemaResult === "object" && "message" in schemaResult) { + return schemaResult; + } -function tPartial

(obj: ZodObject

): ZodObject

{ - return wire(obj.partial() as unknown as ZodObject

); -} + if (typeof schemaResult === "object") { + result = { ...result, ...schemaResult }; + } + } -function tRequired

(obj: ZodObject

): ZodObject

{ - return wire(obj.required() as unknown as ZodObject

); -} + return result; + }; -function tPick

(obj: ZodObject

, keys: readonly K[]): ZodObject> { - const mask = Object.fromEntries(keys.map(k => [k as string, true])); - return wire(obj.pick(mask as never) as unknown as ZodObject>); -} - -function tOmit

(obj: ZodObject

, keys: readonly K[]): ZodObject> { - const mask = Object.fromEntries(keys.map(k => [k as string, true])); - return wire(obj.omit(mask as never) as unknown as ZodObject>); -} -function tComposite(objects: readonly ZodObject[], opts?: Meta): ZodObject { - // `Type.Composite([...])` flattens every object schema into one object schema - // rather than producing an intersection. Mirror that via repeated `extend`. - if (objects.length === 0) return withMeta(z.object({}), opts) as ZodObject; - let out = objects[0] as ZodObject; - for (let i = 1; i < objects.length; i += 1) { - out = out.extend(objects[i].shape) as ZodObject; - } - return withMeta(out, opts) as ZodObject; + return applyMeta(createArkSchema(validator), opts); } // --------------------------------------------------------------------------- diff --git a/packages/coding-agent/src/goals/tools/goal-tool.ts b/packages/coding-agent/src/goals/tools/goal-tool.ts index 056f4baad..125f520b5 100644 --- a/packages/coding-agent/src/goals/tools/goal-tool.ts +++ b/packages/coding-agent/src/goals/tools/goal-tool.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { formatNumber, prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme, ThemeColor } from "../../modes/theme/theme"; import goalDescription from "../../prompts/tools/goal.md" with { type: "text" }; @@ -14,13 +14,13 @@ import { framedBlock, renderStatusLine, truncateToWidth } from "../../tui"; import { completionBudgetReport, remainingTokens } from "../runtime"; import type { Goal, GoalStatus, GoalToolDetails } from "../state"; -const goalSchema = z.object({ - op: z.enum(["create", "get", "complete", "resume", "drop"]).describe("goal operation"), - objective: z.string().describe("goal objective").optional(), - token_budget: z.number().int().describe("token budget").optional(), +const goalSchema = type({ + op: type("'create' | 'get' | 'complete' | 'resume' | 'drop'").describe("goal operation"), + "objective?": type("string").describe("goal objective"), + "token_budget?": type("number.integer").describe("token budget"), }); -export type GoalToolInput = z.infer; +export type GoalToolInput = typeof goalSchema.infer; export interface GoalToolResponse { goal: Goal | null; diff --git a/packages/coding-agent/src/lsp/types.ts b/packages/coding-agent/src/lsp/types.ts index f131ec9ca..d33b8ccca 100644 --- a/packages/coding-agent/src/lsp/types.ts +++ b/packages/coding-agent/src/lsp/types.ts @@ -1,38 +1,24 @@ import type { ptree } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; // ============================================================================= // Tool Schema // ============================================================================= -export const lspSchema = z.object({ - action: z.enum([ - "diagnostics", - "definition", - "references", - "hover", - "symbols", - "rename", - "rename_file", - "code_actions", - "type_definition", - "implementation", - "status", - "reload", - "capabilities", - "request", - ]), - file: z.string().describe("file path or source path for rename_file").optional(), - line: z.number().describe("line number (1-indexed)").optional(), - symbol: z.string().describe("symbol substring on the line").optional(), - query: z.string().describe("search query or code-action selector").optional(), - new_name: z.string().describe("new symbol name or destination path").optional(), - apply: z.boolean().describe("apply edits").optional(), - timeout: z.number().describe("request timeout in seconds").optional(), - payload: z.string().describe("json-encoded request params").optional(), +export const lspSchema = type({ + action: + "'diagnostics' | 'definition' | 'references' | 'hover' | 'symbols' | 'rename' | 'rename_file' | 'code_actions' | 'type_definition' | 'implementation' | 'status' | 'reload' | 'capabilities' | 'request'", + file: "string?", + line: "number?", + symbol: "string?", + query: "string?", + new_name: "string?", + apply: "boolean?", + timeout: "number?", + payload: "string?", }); -export type LspParams = z.infer; +export type LspParams = typeof lspSchema.infer; export interface LspToolDetails { serverName?: string; diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 298ae6250..c779f6017 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -11,9 +11,9 @@ import { } from "@oh-my-pi/pi-natives"; import type { EditorTheme, MarkdownTheme, SelectListTheme, SettingsListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import chalk from "chalk"; import { LRUCache } from "lru-cache/raw"; -import { z } from "zod/v4"; // Embed theme JSON files at build time import darkThemeJson from "./dark.json" with { type: "json" }; import { defaultThemes } from "./defaults"; @@ -972,125 +972,100 @@ function normalizeSpinnerFramesOverride( // Types & Schema // ============================================================================ -const colorValueSchema = z.union([ - z.string(), // hex "#ff0000", var ref "primary", or empty "" - z.number().int().min(0).max(255), // 256-color index -]); +type ColorValue = string | number; -type ColorValue = z.infer; - -const THEME_COLOR_KEYS = [ - "accent", - "border", - "borderAccent", - "borderMuted", - "success", - "error", - "warning", - "muted", - "dim", - "text", - "thinkingText", - "selectedBg", - "userMessageBg", - "userMessageText", - "customMessageBg", - "customMessageText", - "customMessageLabel", - "toolPendingBg", - "toolSuccessBg", - "toolErrorBg", - "toolTitle", - "toolOutput", - "mdHeading", - "mdLink", - "mdLinkUrl", - "mdCode", - "mdCodeBlock", - "mdCodeBlockBorder", - "mdQuote", - "mdQuoteBorder", - "mdHr", - "mdListBullet", - "toolDiffAdded", - "toolDiffRemoved", - "toolDiffContext", - "syntaxComment", - "syntaxKeyword", - "syntaxFunction", - "syntaxVariable", - "syntaxString", - "syntaxNumber", - "syntaxType", - "syntaxOperator", - "syntaxPunctuation", - "thinkingOff", - "thinkingMinimal", - "thinkingLow", - "thinkingMedium", - "thinkingHigh", - "thinkingXhigh", - "bashMode", - "pythonMode", - "statusLineBg", - "statusLineSep", - "statusLineModel", - "statusLinePath", - "statusLineGitClean", - "statusLineGitDirty", - "statusLineContext", - "statusLineSpend", - "statusLineStaged", - "statusLineDirty", - "statusLineUntracked", - "statusLineOutput", - "statusLineCost", - "statusLineSubagents", -] as const; - -const themeColorsSchema = z.object( - Object.fromEntries(THEME_COLOR_KEYS.map(key => [key, colorValueSchema])) as unknown as { - [K in (typeof THEME_COLOR_KEYS)[number]]: typeof colorValueSchema; - }, -); - -const spinnerFramesArraySchema = z.array(z.string().min(1)).min(1); -const spinnerFramesSchema = z.union([ - spinnerFramesArraySchema, - z - .object({ - status: spinnerFramesArraySchema.optional(), - activity: spinnerFramesArraySchema.optional(), - }) - .refine(value => value.status !== undefined || value.activity !== undefined, { - message: "spinnerFrames object must define `status` and/or `activity`", - }), -]); - -const symbolPresetSchema = z.enum(["unicode", "nerd", "ascii"]); - -const themeJsonSchema = z.object({ - $schema: z.string().optional(), - name: z.string(), - vars: z.record(z.string(), colorValueSchema).optional(), - colors: themeColorsSchema, - export: z - .object({ - pageBg: colorValueSchema.optional(), - cardBg: colorValueSchema.optional(), - infoBg: colorValueSchema.optional(), - }) - .optional(), - symbols: z - .object({ - preset: symbolPresetSchema.optional(), - overrides: z.record(z.string(), z.string()).optional(), - spinnerFrames: spinnerFramesSchema.optional(), - }) - .optional(), +const themeColorsSchema = type({ + accent: "string | number", + border: "string | number", + borderAccent: "string | number", + borderMuted: "string | number", + success: "string | number", + error: "string | number", + warning: "string | number", + muted: "string | number", + dim: "string | number", + text: "string | number", + thinkingText: "string | number", + selectedBg: "string | number", + userMessageBg: "string | number", + userMessageText: "string | number", + customMessageBg: "string | number", + customMessageText: "string | number", + customMessageLabel: "string | number", + toolPendingBg: "string | number", + toolSuccessBg: "string | number", + toolErrorBg: "string | number", + toolTitle: "string | number", + toolOutput: "string | number", + mdHeading: "string | number", + mdLink: "string | number", + mdLinkUrl: "string | number", + mdCode: "string | number", + mdCodeBlock: "string | number", + mdCodeBlockBorder: "string | number", + mdQuote: "string | number", + mdQuoteBorder: "string | number", + mdHr: "string | number", + mdListBullet: "string | number", + toolDiffAdded: "string | number", + toolDiffRemoved: "string | number", + toolDiffContext: "string | number", + syntaxComment: "string | number", + syntaxKeyword: "string | number", + syntaxFunction: "string | number", + syntaxVariable: "string | number", + syntaxString: "string | number", + syntaxNumber: "string | number", + syntaxType: "string | number", + syntaxOperator: "string | number", + syntaxPunctuation: "string | number", + thinkingOff: "string | number", + thinkingMinimal: "string | number", + thinkingLow: "string | number", + thinkingMedium: "string | number", + thinkingHigh: "string | number", + thinkingXhigh: "string | number", + bashMode: "string | number", + pythonMode: "string | number", + statusLineBg: "string | number", + statusLineSep: "string | number", + statusLineModel: "string | number", + statusLinePath: "string | number", + statusLineGitClean: "string | number", + statusLineGitDirty: "string | number", + statusLineContext: "string | number", + statusLineSpend: "string | number", + statusLineStaged: "string | number", + statusLineDirty: "string | number", + statusLineUntracked: "string | number", + statusLineOutput: "string | number", + statusLineCost: "string | number", + statusLineSubagents: "string | number", }); -type ThemeJson = z.infer; +const spinnerFramesSchema = type("string[]").or({ + "status?": "string[]", + "activity?": "string[]", +}); + +const themeJsonSchema = type({ + "$schema?": "string", + name: "string", + "vars?": "Record", + colors: themeColorsSchema, + "export?": { + "pageBg?": "string | number", + "cardBg?": "string | number", + "infoBg?": "string | number", + }, + "symbols?": { + "preset?": "'unicode' | 'nerd' | 'ascii'", + "overrides?": "Record", + "spinnerFrames?": spinnerFramesSchema, + }, +}); + +type ThemeJson = typeof themeJsonSchema.infer; export type ThemeColor = | "accent" @@ -1952,37 +1927,30 @@ async function loadThemeJson(name: string): Promise { } catch (error) { throw new Error(`Failed to parse theme ${name}: ${error}`); } - const parsed = themeJsonSchema.safeParse(json); - if (!parsed.success) { - const missingColors: string[] = []; - const otherErrors: string[] = []; - - for (const issue of parsed.error.issues) { - const parts = issue.path; - const colorKey = parts.length === 2 && parts[0] === "colors" && typeof parts[1] === "string" ? parts[1] : null; - - if (colorKey && issue.code === "invalid_type" && (issue as { received?: unknown }).received === undefined) { - missingColors.push(colorKey); - } else { - const pathStr = parts.length === 0 ? "/" : `/${parts.map(String).join("/")}`; - otherErrors.push(` - ${pathStr}: ${issue.message}`); - } + let parsed: ThemeJson; + try { + parsed = themeJsonSchema(json) as ThemeJson; + if (parsed instanceof type.errors) { + throw new Error(parsed.summary); } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // Extract color key information if available + const missingColorMatch = errorMessage.match(/missing keys: (.+)/i); + const missingColors: string[] = missingColorMatch ? missingColorMatch[1].split(",").map(s => s.trim()) : []; - let errorMessage = `Invalid theme "${name}":\n`; + let fullErrorMessage = `Invalid theme "${name}":\n`; if (missingColors.length > 0) { - errorMessage += `\nMissing required color tokens:\n`; - errorMessage += missingColors.map(c => ` - ${c}`).join("\n"); - errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`; - errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`; - } - if (otherErrors.length > 0) { - errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; + fullErrorMessage += `\nMissing required color tokens:\n`; + fullErrorMessage += missingColors.map(c => ` - ${c}`).join("\n"); + fullErrorMessage += `\n\nPlease add these colors to your theme's "colors" object.`; + fullErrorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`; } + fullErrorMessage += `\n\nValidation error:\n - ${errorMessage}`; - throw new Error(errorMessage); + throw new Error(fullErrorMessage); } - return parsed.data; + return parsed; } interface CreateThemeOptions { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cfc8a0c05..d37fb4557 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -126,7 +126,6 @@ import { type AdvisorNote, AdvisorRuntime, type AdvisorSeverity, - deriveAdvisorTelemetry, formatAdvisorBatchContent, isAdvisorInterruptImmuneTurnActive, isInterruptingSeverity, @@ -1759,12 +1758,21 @@ export class AgentSession { // Thread the primary's telemetry into the advisor loop so the advisor // model's GenAI spans + usage/cost hooks fire like every other model call, - // stamped with the advisor's own identity (see deriveAdvisorTelemetry). - const advisorTelemetry = deriveAdvisorTelemetry(this.agent.telemetry, { - id: advisorSessionId, - name: MODEL_ROLES.advisor.name, - description: formatModelString(advisorSel.model), - }); + // stamped with the advisor's own identity. `conversationId` is cleared so + // the advisor loop falls back to its own `-advisor` session id for + // `gen_ai.conversation.id` instead of inheriting the primary's + // conversation; undefined telemetry stays undefined (zero-overhead no-op). + const advisorTelemetry = this.agent.telemetry + ? { + ...this.agent.telemetry, + agent: { + id: advisorSessionId, + name: MODEL_ROLES.advisor.name, + description: formatModelString(advisorSel.model), + }, + conversationId: undefined, + } + : undefined; const advisorAgent = new Agent({ initialState: { systemPrompt, diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index fb1d41fdf..480550090 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -1,7 +1,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Usage } from "@oh-my-pi/pi-ai"; import { $env } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { AgentSessionEvent } from "../session/agent-session"; import type { NestedRepoPatch } from "./worktree"; @@ -79,36 +79,19 @@ export const ROLE_LABEL_MAX = 80; /** Schema bound on the raw `role` input, before it is label-normalized at every use site. */ export const ROLE_INPUT_MAX = 256; -/** - * One unit of work. The single-spawn schema is `{ agent, ...taskItemSchema }`; - * the batch schema (`task.batch`) is `{ agent, context, tasks: taskItemSchema[] }`. - * When task isolation is enabled, `isolated` joins the item shape (per-item in - * batch form, top-level in the flat form via the spread). - */ -const taskItemShape = { - id: z.string().max(48).optional().describe("stable agent id; default generated"), - description: z.string().optional().describe("ui label, not seen by subagent"), - role: z - .string() - .max(ROLE_INPUT_MAX) - .optional() - .describe( - "specialist role/expertise this subagent embodies (e.g. 'Rust async-runtime specialist'); shapes its identity and display name", - ), - assignment: z.string().describe("the work; self-contained instructions"), -}; -const isolatedShape = { - isolated: z.boolean().optional().describe("run in isolated env; returns patches"), -}; -const agentShape = { - agent: z.string().describe("agent type to spawn"), -}; -const contextShape = { - context: z.string().describe("shared background prepended to each assignment"), -}; - -export const taskItemSchema = z.object(taskItemShape); -const taskItemSchemaIsolated = z.object({ ...taskItemShape, ...isolatedShape }); +export const taskItemSchema = type({ + id: "string | undefined", + description: "string | undefined", + role: "string | undefined", + assignment: "string", +}); +const taskItemSchemaIsolated = type({ + id: "string | undefined", + description: "string | undefined", + role: "string | undefined", + assignment: "string", + isolated: "boolean | undefined", +}); /** Single task item. Fields are optional defensively: args stream in token by token. */ export interface TaskItem { @@ -124,17 +107,30 @@ export interface TaskItem { isolated?: boolean; } -export const taskSchema = z.object({ ...agentShape, ...taskItemShape, ...isolatedShape }); -const taskSchemaNoIsolation = z.object({ ...agentShape, ...taskItemShape }); -const taskSchemaBatch = z.object({ - ...agentShape, - ...contextShape, - tasks: z.array(taskItemSchemaIsolated).describe("tasks to spawn; one subagent per item"), +export const taskSchema = type({ + agent: "string", + id: "string | undefined", + description: "string | undefined", + role: "string | undefined", + assignment: "string", + isolated: "boolean | undefined", }); -const taskSchemaBatchNoIsolation = z.object({ - ...agentShape, - ...contextShape, - tasks: z.array(taskItemSchema).describe("tasks to spawn; one subagent per item"), +const taskSchemaNoIsolation = type({ + agent: "string", + id: "string | undefined", + description: "string | undefined", + role: "string | undefined", + assignment: "string", +}); +const taskSchemaBatch = type({ + agent: "string", + context: "string", + tasks: taskItemSchemaIsolated.array(), +}); +const taskSchemaBatchNoIsolation = type({ + agent: "string", + context: "string", + tasks: taskItemSchema.array(), }); const ALL_TASK_SCHEMAS = [taskSchema, taskSchemaNoIsolation, taskSchemaBatch, taskSchemaBatchNoIsolation] as const; diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 1bfee97b2..89af4718a 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -19,7 +19,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Markdown, type MarkdownTheme, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type as arkType } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { ExtensionUISelectItem } from "../extensibility/extensions"; import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme"; @@ -34,24 +34,24 @@ import { ToolAbortError } from "./tool-errors"; // Types // ============================================================================= -const OptionItem = z.object({ - label: z.string().describe("display label"), - description: z.string().describe("optional explanatory text displayed below the label").optional(), +const OptionItem = arkType({ + label: "string", + "description?": "string", }); -const QuestionItem = z.object({ - id: z.string().describe("question id"), - question: z.string().describe("question text"), - options: z.array(OptionItem).describe("available options"), - multi: z.boolean().describe("allow multiple selections").optional(), - recommended: z.number().describe("recommended option index").optional(), +const QuestionItem = arkType({ + id: "string", + question: "string", + options: OptionItem.array(), + "multi?": "boolean", + "recommended?": "number", }); -const askSchema = z.object({ - questions: z.array(QuestionItem).min(1).describe("questions to ask"), +const askSchema = arkType({ + questions: QuestionItem.array().atLeastLength(1), }); -export type AskToolInput = z.infer; +export type AskToolInput = typeof askSchema.infer; /** Result for a single question */ export interface QuestionResult { @@ -424,7 +424,7 @@ export class AskTool implements AgentTool { readonly parameters = askSchema; readonly strict = true; - readonly examples: readonly ToolExample>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Single question", call: { diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 768a8158c..adc14d862 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -6,7 +6,7 @@ import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-m import type { Component } from "@oh-my-pi/pi-tui"; import { replaceTabs, Text } from "@oh-my-pi/pi-tui"; import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -35,17 +35,14 @@ import { queueResolveHandler } from "./resolve"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const astEditOpSchema = z.object({ - pat: z.string().describe("ast pattern"), - out: z.string().describe("replacement template"), +const astEditOpSchema = type({ + pat: "string", + out: "string", }); -const astEditSchema = z.object({ - ops: z.array(astEditOpSchema).min(1).describe("rewrite ops"), - paths: z - .array(z.string().describe("file, directory, glob, or internal URL to rewrite")) - .min(1) - .describe("files, directories, globs, or internal URLs to rewrite"), +const astEditSchema = type({ + ops: astEditOpSchema.array().atLeastLength(1), + paths: type("string[]").atLeastLength(1), }); interface AstEditCallOptions { @@ -165,16 +162,18 @@ export interface AstEditToolDetails { cwd?: string; } +type AstEditSchemaInfer = typeof astEditSchema.infer; + export class AstEditTool implements AgentTool { readonly name = "ast_edit"; readonly approval = (args: unknown) => { - const paths = Array.isArray((args as Partial>).paths) - ? ((args as Partial>).paths as string[]) + const paths = Array.isArray((args as Partial).paths) + ? ((args as Partial).paths as string[]) : []; return paths.length > 0 && paths.every(path => isInternalUrlPath(path)) ? "read" : "write"; }; readonly formatApprovalDetails = (args: unknown): string[] => { - const params = args as Partial>; + const params = args as Partial; const lines: string[] = []; const ops = Array.isArray(params.ops) ? params.ops : []; const firstOp = ops[0]; @@ -196,7 +195,7 @@ export class AstEditTool implements AgentTool>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Rename a call site across TypeScript files", call: { @@ -248,7 +247,7 @@ export class AstEditTool implements AgentTool, + params: AstEditSchemaInfer, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 6681b798c..e5273ef54 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -6,7 +6,7 @@ import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; @@ -34,13 +34,10 @@ import { import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const astGrepSchema = z.object({ - pat: z.string().describe("ast pattern"), - paths: z - .array(z.string().describe("file, directory, glob, or internal URL to search")) - .min(1) - .describe("files, directories, globs, or internal URLs to search"), - skip: z.number().default(0).describe("matches to skip").optional(), +const astGrepSchema = type({ + pat: "string", + paths: type("string[]").atLeastLength(1), + "skip?": "number", }); async function runMultiTargetAstGrep( @@ -132,7 +129,7 @@ export class AstGrepTool implements AgentTool>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Search TypeScript files under src", call: { pat: "console.log($$$)", paths: ["src/**/*.ts"] }, @@ -162,7 +159,7 @@ export class AstGrepTool implements AgentTool, + params: typeof astGrepSchema.infer, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index fca599ab7..e04a7429d 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -9,7 +9,7 @@ import type { import type { Component } from "@oh-my-pi/pi-tui"; import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { type BashResult, executeBash } from "../exec/bash-executor"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; @@ -100,16 +100,21 @@ async function saveBashOriginalArtifact(session: ToolSession, originalText: stri } } -const bashSchemaBase = z.object({ - command: z.string().describe("command to execute"), - env: z.record(z.string().regex(BASH_ENV_NAME_PATTERN), z.string()).optional().describe("extra env vars"), - timeout: z.number().default(300).describe("timeout in seconds").optional(), - cwd: z.string().describe("working directory").optional(), - pty: z.boolean().describe("run in pty mode").optional(), +const bashSchemaBase = type({ + command: "string", + "env?": { "[string]": "string" }, + "timeout?": "number", + "cwd?": "string", + "pty?": "boolean", }); -const bashSchemaWithAsync = bashSchemaBase.extend({ - async: z.boolean().describe("run in background").optional(), +const bashSchemaWithAsync = type({ + command: "string", + "env?": { "[string]": "string" }, + "timeout?": "number", + "cwd?": "string", + "pty?": "boolean", + "async?": "boolean", }); type BashToolSchema = typeof bashSchemaBase | typeof bashSchemaWithAsync; @@ -349,7 +354,7 @@ function stripExitCodeNotice(text: string, exitCode: number | undefined): string * * Executes bash commands with optional timeout and working directory. */ -export class BashTool implements AgentTool { +export class BashTool implements AgentTool { readonly name = "bash"; readonly approval = (args: unknown): ToolApprovalDecision => { const rawCommand = (args as Partial).command; diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 87c7c17cf..cb38a1e53 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -1,7 +1,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import browserDescription from "../prompts/tools/browser.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { enforceInlineByteCap } from "../session/streaming-output"; @@ -23,41 +23,33 @@ export type { Observation, ObservationEntry } from "./browser/tab-protocol"; const DEFAULT_TAB_NAME = "main"; -const appSchema = z.object({ - path: z.string().describe("binary path to spawn").optional(), - cdp_url: z.string().describe("existing cdp endpoint").optional(), - args: z.array(z.string()).describe("extra cli args").optional(), - target: z.string().describe("substring to pick a window").optional(), +const appSchema = type({ + "path?": "string", + "cdp_url?": "string", + "args?": "string[]", + "target?": "string", }); -const browserSchema = z.object({ - action: z.enum(["open", "close", "run"] as const).describe("operation"), - name: z.string().describe("tab id (default 'main')").optional(), - url: z.string().describe("url to open").optional(), - app: appSchema.optional(), - viewport: z - .object({ - width: z.number(), - height: z.number(), - scale: z.number().optional(), - }) - .optional(), - wait_until: z - .enum(["load", "domcontentloaded", "networkidle0", "networkidle2"] as const) - .describe("navigation wait condition") - .optional(), - dialogs: z - .enum(["accept", "dismiss"] as const) - .describe("auto-handle dialogs") - .optional(), - code: z.string().describe("js body to run in tab").optional(), - timeout: z.number().default(30).describe("timeout in seconds (default 30, max 300)").optional(), - all: z.boolean().describe("close every tab").optional(), - kill: z.boolean().describe("also kill spawned-app browsers").optional(), +const browserSchema = type({ + action: "'open' | 'close' | 'run'", + "name?": "string", + "url?": "string", + "app?": appSchema, + "viewport?": { + width: "number", + height: "number", + "scale?": "number", + }, + "wait_until?": "'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'", + "dialogs?": "'accept' | 'dismiss'", + "code?": "string", + "timeout?": "number", + "all?": "boolean", + "kill?": "boolean", }); /** Input schema for the browser tool. */ -export type BrowserParams = z.infer; +export type BrowserParams = typeof browserSchema.infer; /** Details describing a browser tool execution result (for renderers + transcript). */ export interface BrowserToolDetails { @@ -119,7 +111,7 @@ export class BrowserTool implements AgentTool>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Open a tab", call: { action: "open", name: "docs", url: "https://example.com" }, diff --git a/packages/coding-agent/src/tools/checkpoint.ts b/packages/coding-agent/src/tools/checkpoint.ts index eee16ba60..821de6ce7 100644 --- a/packages/coding-agent/src/tools/checkpoint.ts +++ b/packages/coding-agent/src/tools/checkpoint.ts @@ -1,6 +1,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import checkpointDescription from "../prompts/tools/checkpoint.md" with { type: "text" }; import rewindDescription from "../prompts/tools/rewind.md" with { type: "text" }; import type { ToolSession } from "."; @@ -17,17 +17,17 @@ export interface CheckpointState { startedAt: string; } -const checkpointSchema = z.object({ - goal: z.string().describe("investigation goal"), +const checkpointSchema = type({ + goal: "string", }); -type CheckpointParams = z.infer; +type CheckpointParams = typeof checkpointSchema.infer; -const rewindSchema = z.object({ - report: z.string().describe("investigation findings"), +const rewindSchema = type({ + report: "string", }); -type RewindParams = z.infer; +type RewindParams = typeof rewindSchema.infer; export interface CheckpointToolDetails { goal: string; diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 25e84a29b..40970ed31 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -10,7 +10,7 @@ import type { import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { isEnoent, prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { type DapBreakpointRecord, type DapCapabilities, @@ -74,75 +74,78 @@ export const DEBUG_READONLY_ACTIONS: ReadonlySet = new Set([ "modules", "sessions", ]); -const debugSchema = z.object({ - action: z.enum([ - "launch", - "attach", - "set_breakpoint", - "remove_breakpoint", - "set_instruction_breakpoint", - "remove_instruction_breakpoint", - "data_breakpoint_info", - "set_data_breakpoint", - "remove_data_breakpoint", - "continue", - "step_over", - "step_in", - "step_out", - "pause", - "evaluate", - "stack_trace", - "threads", - "scopes", - "variables", - "disassemble", - "read_memory", - "write_memory", - "modules", - "loaded_sources", - "custom_request", - "output", - "terminate", - "sessions", - ] as const), - program: z.string().describe("program path").optional(), - args: z.array(z.string()).describe("program arguments").optional(), - adapter: z.string().describe("debugger adapter (gdb, lldb-dap, debugpy, dlv)").optional(), - cwd: z.string().optional(), - file: z.string().describe("source file").optional(), - line: z.number().describe("source line").optional(), - function: z.string().describe("function name").optional(), - name: z.string().describe("variable or data name").optional(), - condition: z.string().describe("breakpoint condition").optional(), - hit_condition: z.string().optional(), - expression: z.string().describe("expression to evaluate").optional(), - context: z.string().describe("evaluate context: watch | repl | hover | variables | clipboard").optional(), - frame_id: z.number().optional(), - scope_id: z.number().describe("scope variables reference").optional(), - variable_ref: z.number().describe("variable reference").optional(), - pid: z.number().describe("process id for attach").optional(), - port: z.number().describe("remote attach port").optional(), - host: z.string().describe("remote attach host").optional(), - levels: z.number().describe("max stack frames").optional(), - memory_reference: z.string().describe("memory reference or address").optional(), - instruction_reference: z.string().optional(), - instruction_count: z.number().optional(), - instruction_offset: z.number().optional(), - count: z.number().describe("bytes to read").optional(), - data: z.string().describe("base64 memory payload").optional(), - data_id: z.string().describe("data breakpoint id").optional(), - access_type: z.enum(["read", "write", "readWrite"] as const).optional(), - command: z.string().describe("custom dap request command").optional(), - arguments: z.record(z.string(), z.any()).describe("custom request arguments").optional(), - offset: z.number().optional(), - resolve_symbols: z.boolean().optional(), - allow_partial: z.boolean().optional(), - start_module: z.number().optional(), - module_count: z.number().optional(), - timeout: z.number().describe("per-request timeout seconds").optional(), +const debugActionSchema = type.enumerated( + "launch", + "attach", + "set_breakpoint", + "remove_breakpoint", + "set_instruction_breakpoint", + "remove_instruction_breakpoint", + "data_breakpoint_info", + "set_data_breakpoint", + "remove_data_breakpoint", + "continue", + "step_over", + "step_in", + "step_out", + "pause", + "evaluate", + "stack_trace", + "threads", + "scopes", + "variables", + "disassemble", + "read_memory", + "write_memory", + "modules", + "loaded_sources", + "custom_request", + "output", + "terminate", + "sessions", +); +const debugSchema = type({ + action: debugActionSchema, + program: "string?", + args: "string[]?", + adapter: "string?", + cwd: "string?", + file: "string?", + line: "number?", + function: "string?", + name: "string?", + condition: "string?", + hit_condition: "string?", + expression: "string?", + context: "string?", + frame_id: "number?", + scope_id: "number?", + variable_ref: "number?", + pid: "number?", + port: "number?", + host: "string?", + levels: "number?", + memory_reference: "string?", + instruction_reference: "string?", + instruction_count: "number?", + instruction_offset: "number?", + count: "number?", + data: "string?", + data_id: "string?", + "access_type?": "'read' | 'write' | 'readWrite'", + command: "string?", + "arguments?": { + "[string]": "unknown", + }, + offset: "number?", + resolve_symbols: "boolean?", + allow_partial: "boolean?", + start_module: "number?", + module_count: "number?", + timeout: "number?", }); -export type DebugParams = z.infer; +export type DebugParams = typeof debugSchema.infer; export type DebugAction = DebugParams["action"]; interface DebugToolDetails { @@ -661,7 +664,7 @@ export class DebugTool implements AgentTool>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Launch and inspect hang", note: '1. debug(action: "launch", program: "./my_app")\n2. debug(action: "set_breakpoint", file: "src/main.c", line: 42)\n3. debug(action: "continue")\n4. If the program appears hung: debug(action: "pause")\n5. Inspect state with `threads`, `stack_trace`, `scopes`, and `variables`', diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index f4d04bb50..8be4ee484 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -1,7 +1,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { jsBackend, pythonBackend } from "../eval"; import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend"; import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../eval/bridge-timeout"; @@ -27,25 +27,19 @@ export { EVAL_DEFAULT_PREVIEW_LINES, evalToolRenderer } from "./eval-render"; * Per-cell input. Each cell runs in order; state persists within a language * across cells and across tool calls. */ -const evalCellSchema = z.object({ - language: z.enum(["py", "js"]).describe('runtime: "py" for the IPython kernel, "js" for the persistent JS VM'), - code: z.string().describe("cell body, verbatim. Use top-level await freely."), - title: z.string().optional().describe('short label shown in transcript (e.g. "imports", "load config")'), - timeout: z.number().int().min(1).max(3600).optional().describe("per-cell timeout in seconds (1-3600, default 30)"), - reset: z - .boolean() - .optional() - .describe("wipe this cell's language kernel before running. Other languages are untouched."), +const evalCellSchema = type({ + language: "'py' | 'js'", + code: "string", + "title?": "string", + "timeout?": "number", + "reset?": "boolean", }); -export type EvalCellInput = z.infer; +export type EvalCellInput = typeof evalCellSchema.infer; -export const evalSchema = z.object({ - cells: z - .array(evalCellSchema) - .min(1) - .describe("cells executed in order. State persists within each language across cells and tool calls."), +export const evalSchema = type({ + cells: evalCellSchema.array().atLeastLength(1), }); -export type EvalToolParams = z.infer; +export type EvalToolParams = typeof evalSchema.infer; export type EvalToolResult = { content: Array<{ type: "text"; text: string }>; @@ -183,7 +177,7 @@ export class EvalTool implements AgentTool { const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null; return getEvalToolDescription({ py: backends.python, js: backends.js, spawns: spawnsAllowed }); } - readonly examples: readonly ToolExample>[] = [ + readonly examples: readonly ToolExample[] = [ { call: { cells: [ @@ -205,7 +199,7 @@ export class EvalTool implements AgentTool { readonly parameters = evalSchema; readonly concurrency = "exclusive"; readonly strict = true; - readonly intent = (args: Partial>): string | undefined => { + readonly intent = (args: Partial): string | undefined => { const cells = Array.isArray(args.cells) ? args.cells : []; const first = cells.find(c => c && typeof c === "object"); if (!first) return "evaluating"; @@ -226,7 +220,7 @@ export class EvalTool implements AgentTool { async execute( _toolCallId: string, - params: z.infer, + params: typeof evalSchema.infer, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, _ctx?: AgentToolContext, diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index db6af4359..d62d9600f 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -6,7 +6,7 @@ import * as natives from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; @@ -36,17 +36,20 @@ import { import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; -const findSchema = z - .object({ - paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"), - hidden: z.boolean().default(true).describe("include hidden files").optional(), - gitignore: z.boolean().default(true).describe("respect gitignore").optional(), - limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(), - timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(), - }) - .strict(); +const findSchema = type({ + paths: "string[] >= 1", + "hidden?": "boolean", + "gitignore?": "boolean", + "limit?": "number", + "timeout?": "number", +}).narrow((o, ctx) => { + if (o.timeout !== undefined && (o.timeout < 0.5 || o.timeout > 60)) { + return ctx.mustBe("a timeout between 0.5 and 60 seconds"); + } + return true; +}); -export type FindToolInput = z.infer; +export type FindToolInput = typeof findSchema.infer; const DEFAULT_LIMIT = 200; const MAX_LIMIT = 200; @@ -108,7 +111,7 @@ export class FindTool implements AgentTool { readonly description: string; readonly parameters = findSchema; - readonly examples: readonly ToolExample>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Find files", call: { paths: ["src/**/*.ts"] }, @@ -144,7 +147,7 @@ export class FindTool implements AgentTool { async execute( _toolCallId: string, - params: z.infer, + params: typeof findSchema.infer, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index cd417dab6..a9b16066c 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -11,7 +11,7 @@ import type { } from "@oh-my-pi/pi-agent-core"; import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { Settings } from "../config/settings"; import githubDescription from "../prompts/tools/github.md" with { type: "text" }; import * as git from "../utils/git"; @@ -255,54 +255,32 @@ const GITHUB_READONLY_OPS: ReadonlySet = new Set([ "run_watch", ]); -const githubSchema = z - .object({ - op: z - .enum([ - "repo_view", - "pr_create", - "pr_checkout", - "pr_push", - "search_issues", - "search_prs", - "search_code", - "search_commits", - "search_repos", - "run_watch", - ] as const) - .describe("github operation"), - repo: z.string().describe("owner/repo").optional(), - branch: z.string().describe("branch").optional(), - pr: z - .union([z.string(), z.array(z.string())]) - .describe("pr number, url, or branch") - .optional(), - force: z.boolean().describe("reset existing local branch").optional(), - forceWithLease: z.boolean().describe("force-with-lease push").optional(), - title: z.string().describe("pr title").optional(), - body: z.string().describe("pr body markdown").optional(), - base: z.string().describe("pr base branch").optional(), - head: z.string().describe("pr head branch").optional(), - draft: z.boolean().describe("open pr as draft").optional(), - fill: z.boolean().describe("auto-fill pr title/body from commits").optional(), - reviewer: z.array(z.string()).describe("reviewers").optional(), - assignee: z.array(z.string()).describe("assignees").optional(), - label: z.array(z.string()).describe("labels").optional(), - query: z.string().describe("search query").optional(), - since: z.string().describe("lower-bound date filter").optional(), - until: z.string().describe("upper-bound date filter").optional(), - dateField: z - .enum(["created", "updated"] as const) - .describe("date field") - .default("created") - .optional(), - limit: z.number().default(10).describe("max results").optional(), - run: z.string().describe("actions run id or url").optional(), - tail: z.number().default(15).describe("log lines per failed job").optional(), - }) - .strict(); +const githubSchema = type({ + op: "'repo_view' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'", + repo: "string?", + branch: "string?", + pr: "(string | string[])?", + force: "boolean?", + forceWithLease: "boolean?", + title: "string?", + body: "string?", + base: "string?", + head: "string?", + draft: "boolean?", + fill: "boolean?", + reviewer: "string[]?", + assignee: "string[]?", + label: "string[]?", + query: "string?", + since: "string?", + until: "string?", + "dateField?": "'created' | 'updated'", + "limit?": "number", + run: "string?", + "tail?": "number", +}); -type GithubInput = z.infer; +type GithubInput = typeof githubSchema.infer; export interface GhToolDetails { meta?: OutputMeta; diff --git a/packages/coding-agent/src/tools/image-gen.ts b/packages/coding-agent/src/tools/image-gen.ts index 07d25e4d2..7089f9dfd 100644 --- a/packages/coding-agent/src/tools/image-gen.ts +++ b/packages/coding-agent/src/tools/image-gen.ts @@ -1,14 +1,6 @@ import * as os from "node:os"; import * as path from "node:path"; -import { - type ApiKey, - type FetchImpl, - getEnvApiKey, - getOpenRouterHeaders, - type Model, - ProviderHttpError, - withAuth, -} from "@oh-my-pi/pi-ai"; +import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai"; import { CODEX_BASE_URL, getCodexAccountId, @@ -27,7 +19,8 @@ import { Snowflake, untilAborted, } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; +import packageJson from "../../package.json" with { type: "json" }; import { isAuthenticated, type ModelRegistry } from "../config/model-registry"; import { settings } from "../config/settings"; import type { CustomTool } from "../extensibility/custom-tools/types"; @@ -65,37 +58,32 @@ const XAI_IMAGE_ASPECT_RATIOS = [...COMMON_IMAGE_ASPECT_RATIOS, "3:2", "2:3"] as const COMMON_IMAGE_ASPECT_RATIO_SET = new Set(COMMON_IMAGE_ASPECT_RATIOS); const IMAGE_PROVIDER_PREFERENCES = new Set(["auto", "antigravity", "gemini", "openai", "openrouter", "xai"]); -const responseModalitySchema = z.enum(["IMAGE", "TEXT"] as const); -const aspectRatioSchema = z.enum(XAI_IMAGE_ASPECT_RATIOS).describe("aspect ratio"); -const imageSizeSchema = z.enum(["1024x1024", "1536x1024", "1024x1536"] as const).describe("image size"); +const responseModalitySchema = type('"IMAGE" | "TEXT"'); -const inputImageSchema = z - .object({ - path: z.string().describe("input image path").optional(), - data: z.string().describe("base64 image data").optional(), - mime_type: z.string().describe("mime type").optional(), - }) - .strict(); +const aspectRatioSchema = type.enumerated(...XAI_IMAGE_ASPECT_RATIOS); +const imageSizeSchema = type('"1024x1024" | "1536x1024" | "1024x1536"'); -const baseImageSchema = z - .object({ - subject: z.string().describe("main subject"), - action: z.string().describe("what subject is doing").optional(), - scene: z.string().describe("location or environment").optional(), - composition: z.string().describe("camera angle and framing").optional(), - lighting: z.string().describe("lighting setup").optional(), - style: z.string().describe("artistic style").optional(), - text: z.string().describe("text to render").optional(), - changes: z.array(z.string()).describe("edits to make").optional(), - aspect_ratio: aspectRatioSchema.optional(), - image_size: imageSizeSchema.optional(), - input: z.array(inputImageSchema).describe("input images").optional(), - }) - .strict(); +const inputImageSchema = type({ + "path?": "string", + "data?": "string", + "mime_type?": "string", +}); -export const imageGenSchema = baseImageSchema; -export type ImageGenParams = z.infer; -export type GeminiResponseModality = z.infer; +export const imageGenSchema = type({ + subject: "string", + "action?": "string", + "scene?": "string", + "composition?": "string", + "lighting?": "string", + "style?": "string", + "text?": "string", + "changes?": "string[]", + "aspect_ratio?": aspectRatioSchema, + "image_size?": imageSizeSchema, + "input?": inputImageSchema.array(), +}); +export type ImageGenParams = typeof imageGenSchema.infer; +export type GeminiResponseModality = typeof responseModalitySchema.infer; /** * Assembles a structured prompt from the provided parameters. @@ -1414,7 +1402,9 @@ export const imageGenTool: CustomTool; +export type InspectImageParams = typeof inspectImageSchema.infer; export interface InspectImageToolDetails { model: string; @@ -43,7 +41,7 @@ export class InspectImageTool implements AgentTool>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "OCR with strict formatting", call: { diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index 0856917ad..deb25f3d9 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -13,7 +13,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { formatAge, formatDuration, prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { Settings } from "../config/settings"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../irc/bus"; @@ -49,18 +49,18 @@ export function isIrcEnabled(settings: Settings, taskDepth: number): boolean { return canSpawnAtDepth(maxDepth, taskDepth); } -const ircSchema = z.object({ - op: z.enum(["send", "wait", "inbox", "list"]).describe("irc operation"), - to: z.string().optional().describe('send: recipient agent id or "all"'), - message: z.string().optional().describe("send: message body"), - replyTo: z.string().optional().describe("send: message id being answered"), - await: z.boolean().optional().describe('send: wait for the recipient\'s reply (invalid with to:"all")'), - from: z.string().optional().describe("wait: only accept a message from this agent id"), - timeoutMs: z.number().optional().describe("wait: timeout in milliseconds (0 waits indefinitely)"), - peek: z.boolean().optional().describe("inbox: list messages without consuming them"), +const ircSchema = type({ + op: "'send' | 'wait' | 'inbox' | 'list'", + "to?": "string", + "message?": "string", + "replyTo?": "string", + "await?": "boolean", + "from?": "string", + "timeoutMs?": "number", + "peek?": "boolean", }); -type IrcParams = z.infer; +type IrcParams = typeof ircSchema.infer; interface IrcPeerInfo { id: string; @@ -98,7 +98,7 @@ export class IrcTool implements AgentTool { readonly parameters = ircSchema; readonly strict = true; - readonly examples: readonly ToolExample>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "List peers", call: { op: "list" }, diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index f72d2e48f..dc71fe21e 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { AsyncJob, AsyncJobManager } from "../async"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer"; @@ -23,13 +23,13 @@ import { } from "./render-utils"; import { ToolError } from "./tool-errors"; -const jobSchema = z.object({ - poll: z.array(z.string()).optional().describe("job ids to wait for; omit to wait on all running jobs"), - cancel: z.array(z.string()).optional().describe("job ids to cancel"), - list: z.boolean().optional().describe("snapshot all jobs"), +const jobSchema = type({ + "poll?": "string[]", + "cancel?": "string[]", + "list?": "boolean", }); -type JobParams = z.infer; +type JobParams = typeof jobSchema.infer; const WAIT_DURATION_MS: Record = { "5s": 5_000, diff --git a/packages/coding-agent/src/tools/learn.ts b/packages/coding-agent/src/tools/learn.ts index e89232770..faac481c0 100644 --- a/packages/coding-agent/src/tools/learn.ts +++ b/packages/coding-agent/src/tools/learn.ts @@ -1,26 +1,23 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { sanitizeSkillName, writeManagedSkill } from "../autolearn/managed-skills"; import { isNameClaimedByAuthoredSkill } from "../extensibility/skills"; import { localBackend } from "../memory-backend/local-backend"; import learnDescription from "../prompts/tools/learn.md" with { type: "text" }; import type { ToolSession } from "."; -const learnSchema = z.object({ - memory: z.string().describe("the durable, self-contained lesson to remember (what, when, why)"), - context: z.string().describe("optional source context for the lesson").optional(), - skill: z - .object({ - action: z.enum(["create", "update"]), - name: z.string().describe("kebab-case skill name"), - description: z.string().describe("one-line description of when to use the skill"), - body: z.string().describe("the SKILL.md body in markdown (no frontmatter)"), - }) - .describe("also create or enhance a managed skill in the same call") - .optional(), +const learnSchema = type({ + memory: type("string").describe("the durable, self-contained lesson to remember (what, when, why)"), + "context?": type("string").describe("optional source context for the lesson"), + "skill?": type({ + action: "'create' | 'update'", + name: type("string").describe("kebab-case skill name"), + description: type("string").describe("one-line description of when to use the skill"), + body: type("string").describe("the SKILL.md body in markdown (no frontmatter)"), + }).describe("also create or enhance a managed skill in the same call"), }); -export type LearnParams = z.infer; +export type LearnParams = typeof learnSchema.infer; /** * Orchestrating "learn" tool: persists a lesson to long-term memory and, diff --git a/packages/coding-agent/src/tools/manage-skill.ts b/packages/coding-agent/src/tools/manage-skill.ts index 94f925ad6..75b6d6fe4 100644 --- a/packages/coding-agent/src/tools/manage-skill.ts +++ b/packages/coding-agent/src/tools/manage-skill.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { deleteManagedSkill, getManagedSkillsDir, @@ -11,29 +11,23 @@ import { isNameClaimedByAuthoredSkill } from "../extensibility/skills"; import manageSkillDescription from "../prompts/tools/manage-skill.md" with { type: "text" }; import type { ToolSession } from "."; -const manageSkillSchema = z - .object({ - action: z.enum(["create", "update", "delete"]), - name: z.string().describe("kebab-case skill name"), - description: z - .string() - .describe("one-line description of when to use the skill (required for create/update)") - .optional(), - body: z - .string() - .describe("the SKILL.md body in markdown, no frontmatter (required for create/update)") - .optional(), - }) - // Enforce the action/field contract at validation time rather than only in - // execute. Kept as a cross-field refine (not a discriminated union) so the - // wire schema stays a single root object — strict structured-output mode and - // the Anthropic tool-schema builder both require that. - .refine(p => p.action === "delete" || (p.description !== undefined && p.body !== undefined), { - message: '"create" and "update" require both "description" and "body".', - path: ["description"], - }); +const manageSkillSchema = type({ + action: "'create' | 'update' | 'delete'", + name: "string", + description: "string?", + body: "string?", +}).narrow( + (p, ctx) => + p.action === "delete" || + (p.description !== undefined && p.body !== undefined) || + // Enforce the action/field contract at validation time rather than only in + // execute. Kept as a cross-field narrow (not a discriminated union) so the + // wire schema stays a single root object — strict structured-output mode and + // the Anthropic tool-schema builder both require that. + ctx.mustBe('used with both "description" and "body" for "create" and "update"'), +); -export type ManageSkillParams = z.infer; +export type ManageSkillParams = typeof manageSkillSchema.infer; /** * Direct create/update/delete of isolated managed skills. Gated behind diff --git a/packages/coding-agent/src/tools/memory-edit.ts b/packages/coding-agent/src/tools/memory-edit.ts index 123ee6ed4..a620e4cf8 100644 --- a/packages/coding-agent/src/tools/memory-edit.ts +++ b/packages/coding-agent/src/tools/memory-edit.ts @@ -1,17 +1,17 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { z } from "zod/v4"; +import { type } from "arktype"; import memoryEditDescription from "../prompts/tools/memory-edit.md" with { type: "text" }; import type { ToolSession } from "."; -const memoryEditSchema = z.object({ - op: z.enum(["update", "forget", "invalidate"]).describe("memory edit operation"), - id: z.string().describe("memory id from recall output"), - content: z.string().optional().describe("replacement content for update"), - importance: z.number().optional().describe("replacement importance for update, clamped to [0, 1]"), - replacement_id: z.string().optional().describe("replacement memory id for invalidate"), +const memoryEditSchema = type({ + op: "'update' | 'forget' | 'invalidate'", + id: "string", + content: "string?", + importance: "number?", + replacement_id: "string?", }); -export type MemoryEditParams = z.infer; +export type MemoryEditParams = typeof memoryEditSchema.infer; export class MemoryEditTool implements AgentTool { readonly name = "memory_edit"; diff --git a/packages/coding-agent/src/tools/memory-recall.ts b/packages/coding-agent/src/tools/memory-recall.ts index 619573245..4f15a29dd 100644 --- a/packages/coding-agent/src/tools/memory-recall.ts +++ b/packages/coding-agent/src/tools/memory-recall.ts @@ -1,15 +1,15 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { formatCurrentTime, formatMemories } from "../hindsight/content"; import recallDescription from "../prompts/tools/recall.md" with { type: "text" }; import type { ToolSession } from "."; -const memoryRecallSchema = z.object({ - query: z.string().describe("natural language search query"), +const memoryRecallSchema = type({ + query: "string", }); -export type MemoryRecallParams = z.infer; +export type MemoryRecallParams = typeof memoryRecallSchema.infer; export class MemoryRecallTool implements AgentTool { readonly name = "recall"; diff --git a/packages/coding-agent/src/tools/memory-reflect.ts b/packages/coding-agent/src/tools/memory-reflect.ts index 118f52cb0..2237f954b 100644 --- a/packages/coding-agent/src/tools/memory-reflect.ts +++ b/packages/coding-agent/src/tools/memory-reflect.ts @@ -1,16 +1,16 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { ensureBankExists } from "../hindsight/bank"; import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" }; import type { ToolSession } from "."; -const memoryReflectSchema = z.object({ - query: z.string().describe("question to answer"), - context: z.string().describe("optional context").optional(), +const memoryReflectSchema = type({ + query: "string", + "context?": "string", }); -export type MemoryReflectParams = z.infer; +export type MemoryReflectParams = typeof memoryReflectSchema.infer; export class MemoryReflectTool implements AgentTool { readonly name = "reflect"; diff --git a/packages/coding-agent/src/tools/memory-retain.ts b/packages/coding-agent/src/tools/memory-retain.ts index df95f7898..c45d2777c 100644 --- a/packages/coding-agent/src/tools/memory-retain.ts +++ b/packages/coding-agent/src/tools/memory-retain.ts @@ -1,21 +1,18 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { z } from "zod/v4"; +import { type } from "arktype"; import retainDescription from "../prompts/tools/retain.md" with { type: "text" }; import type { ToolSession } from "."; -const memoryRetainSchema = z.object({ - items: z - .array( - z.object({ - content: z.string().describe("information to remember"), - context: z.string().describe("source context").optional(), - }), - ) - .min(1) - .describe("memories to retain"), +const memoryRetainSchema = type({ + items: type({ + content: "string", + "context?": "string", + }) + .array() + .atLeastLength(1), }); -export type MemoryRetainParams = z.infer; +export type MemoryRetainParams = typeof memoryRetainSchema.infer; export class MemoryRetainTool implements AgentTool { readonly name = "retain"; readonly approval = "read" as const; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 5fe64b637..9ac096a66 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1,6 +1,5 @@ import { Database } from "bun:sqlite"; import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; @@ -8,9 +7,9 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { getRemoteDir, type ImageMetadata, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; +import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import { LRUCache } from "lru-cache/raw"; -import { z } from "zod/v4"; import { canonicalSnapshotKey, getFileSnapshotStore, @@ -663,17 +662,11 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from: return text ? `${notice}\n${text}` : notice; } -const readSchema = z - .object({ - path: z - .string() - .describe( - 'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL; append : for line ranges or raw mode (e.g. "src/foo.ts:50-100")', - ), - }) - .strict(); +const readSchema = type({ + path: "string", +}); -export type ReadToolInput = z.infer; +export type ReadToolInput = typeof readSchema.infer; export interface ReadToolDetails { kind?: "file" | "url"; @@ -769,59 +762,6 @@ function selToOffsetLimit(parsed: ParsedSelector): { offset?: number; limit?: nu return {}; } -/** - * markit emits `` placeholders for images - * embedded in a PDF (the same `` it would write as `.png` when given an - * image directory). Matches one placeholder; `id` is `\S+`, the parenthesised - * metadata is captured verbatim. - */ -const PDF_IMAGE_PLACEHOLDER_RE = //g; - -/** A converted-PDF image member is addressed as `:.png`. */ -const PDF_ASSET_RE = /^(.+\.pdf):(.*)$/i; - -/** Image member extensions a converted PDF can expose. */ -const PDF_IMAGE_MEMBER_RE = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i; - -interface PdfAssetTarget { - /** PDF path portion (before the `:` member separator), still unresolved. */ - pdfPath: string; - /** Requested member basename, or `""` to list available members. */ - member: string; -} - -/** - * Turn markit's inert image placeholders into browsable read handles so the - * agent can open a specific diagram with `read :.png`. The handle is - * emitted as inline code, never a markdown link, so PDF paths containing spaces, - * parentheses, or backslashes can't break the surrounding markdown. - */ -function rewritePdfImagePlaceholders(markdown: string, pdfRef: string): string { - return markdown.replace( - PDF_IMAGE_PLACEHOLDER_RE, - (_match, id: string, meta: string) => `[image ${id} (${meta}) — read \`${pdfRef}:${id}.png\` to view]`, - ); -} - -/** - * Recognise a `:` read path that addresses a converted-PDF image - * (or, with an empty member, a listing request). Returns `null` for anything - * the normal read flow must keep owning: non-PDF paths, line/raw/conflicts - * selectors (`doc.pdf:50-100`, `doc.pdf:raw`), and unknown non-image tails. - */ -function parsePdfAssetReadPath(readPath: string): PdfAssetTarget | null { - const match = PDF_ASSET_RE.exec(readPath); - if (!match) return null; - const pdfPath = match[1]!; - const member = match[2]!; - if (member === "") return { pdfPath, member: "" }; - // A real selector (line range / raw / conflicts) belongs to the normal flow. - if (parseSel(member).kind !== "none") return null; - // An unrecognised, non-image tail also stays on the normal flow (whole doc). - if (!PDF_IMAGE_MEMBER_RE.test(member)) return null; - return { pdfPath, member }; -} - interface ResolvedArchiveReadPath { absolutePath: string; archiveSubPath: string; @@ -940,203 +880,6 @@ export class ReadTool implements AgentTool { return result; } - /** - * Emit read-tool content for an image file at `absolutePath`. Shared by the - * direct image-read branch and converted-PDF image members. Honors - * `inspect_image.enabled` (metadata + tool hint) versus inlining the image. - */ - async #buildImageContent(opts: { - absolutePath: string; - mimeType: string; - fileSize: number; - imageMetadata: ImageMetadata | null; - }): Promise<{ content: Array; sourcePath: string }> { - const { absolutePath, mimeType, fileSize, imageMetadata } = opts; - if (this.#inspectImageEnabled) { - const outputMime = imageMetadata?.mimeType ?? mimeType; - const metadataLines = [ - "Image metadata:", - `- MIME: ${outputMime}`, - `- Bytes: ${fileSize} (${formatBytes(fileSize)})`, - imageMetadata?.width !== undefined && imageMetadata.height !== undefined - ? `- Dimensions: ${imageMetadata.width}x${imageMetadata.height}` - : "- Dimensions: unknown", - imageMetadata?.channels !== undefined ? `- Channels: ${imageMetadata.channels}` : "- Channels: unknown", - imageMetadata?.hasAlpha === true - ? "- Alpha: yes" - : imageMetadata?.hasAlpha === false - ? "- Alpha: no" - : "- Alpha: unknown", - "", - `If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd( - absolutePath, - this.session.cwd, - )}" and a question describing what to inspect and the desired output format.`, - ]; - return { content: [{ type: "text", text: metadataLines.join("\n") }], sourcePath: absolutePath }; - } - if (fileSize > MAX_IMAGE_SIZE) { - throw new ToolError( - `Image file too large: ${formatBytes(fileSize)} exceeds ${formatBytes(MAX_IMAGE_SIZE)} limit.`, - ); - } - try { - const imageInput = await loadImageInput({ - path: absolutePath, - cwd: this.session.cwd, - autoResize: this.#autoResizeImages, - maxBytes: MAX_IMAGE_SIZE, - resolvedPath: absolutePath, - detectedMimeType: mimeType, - excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()), - }); - if (!imageInput) { - throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`); - } - return { - content: [ - { type: "text", text: imageInput.textNote }, - { type: "image", data: imageInput.data, mimeType: imageInput.mimeType }, - ], - sourcePath: imageInput.resolvedPath, - }; - } catch (error) { - if (error instanceof ImageInputTooLargeError) { - throw new ToolError(error.message); - } - throw error; - } - } - - /** - * Read a converted-PDF image member (`:.png`) or, with an empty - * member, list the extractable images. The PDF is converted once into a - * stable session-artifact cache; members are served from that cache through - * the normal image-loading path. - */ - async #readPdfAsset( - target: PdfAssetTarget, - suffixCache: SuffixMatchCache, - signal?: AbortSignal, - ): Promise> { - throwIfAborted(signal); - // Resolve the PDF base path, mirroring the archive resolver's suffix fallback. - let absolutePath = resolveReadPath(target.pdfPath, this.session.cwd); - let suffixResolution: { from: string; to: string } | undefined; - let resolved = false; - try { - const stat = await Bun.file(absolutePath).stat(); - resolved = !stat.isDirectory(); - } catch (error) { - if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) throw error; - } - if (!resolved) { - const suffixMatch = await this.#findSuffixMatchCached(suffixCache, target.pdfPath, signal); - if (suffixMatch) { - try { - const retryStat = await Bun.file(suffixMatch.absolutePath).stat(); - if (!retryStat.isDirectory()) { - absolutePath = suffixMatch.absolutePath; - suffixResolution = { from: target.pdfPath, to: suffixMatch.displayPath }; - resolved = true; - } - } catch { - // fall through to not-found - } - } - } - if (!resolved) { - throw new ToolError(`Path '${target.pdfPath}' not found`); - } - - const { dir, members } = await this.#ensurePdfImagesExtracted(absolutePath, signal); - const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd); - - // Listing mode: enumerate browsable members. - if (target.member === "") { - const pdfRef = suffixResolution?.to ?? target.pdfPath; - const body = - members.length === 0 - ? `No extractable images found in ${displayPath}.` - : `${members.length} image${members.length === 1 ? "" : "s"} in ${displayPath}:\n${members - .map(name => `- read \`${pdfRef}:${name}\``) - .join("\n")}`; - return toolResult({ resolvedPath: absolutePath, suffixResolution }) - .text(prependSuffixResolutionNotice(body, suffixResolution)) - .sourcePath(absolutePath) - .done(); - } - - // Membership check guards against path traversal: only known basenames serve. - if (!members.includes(target.member)) { - const available = members.length === 0 ? "(none)" : members.map(name => `\`${name}\``).join(", "); - throw new ToolError(`Image '${target.member}' not found in ${displayPath}. Available: ${available}`); - } - - const memberPath = path.join(dir, target.member); - const memberStat = await Bun.file(memberPath).stat(); - const memberMeta = await readImageMetadata(memberPath); - const { content, sourcePath } = await this.#buildImageContent({ - absolutePath: memberPath, - mimeType: memberMeta?.mimeType ?? "image/png", - fileSize: memberStat.size, - imageMetadata: memberMeta, - }); - if (suffixResolution) { - const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`; - const firstText = content.find((c): c is TextContent => c.type === "text"); - if (firstText) firstText.text = `${notice}\n${firstText.text}`; - else content.unshift({ type: "text", text: notice }); - } - return toolResult({ resolvedPath: memberPath, suffixResolution }) - .content(content) - .sourcePath(sourcePath) - .done(); - } - - /** - * Convert `absolutePath` once into a stable per-file image cache and return - * the cache dir plus extracted image basenames. Keyed by file size+mtime so - * edits invalidate; a `.extracted` marker skips re-conversion on later reads. - */ - async #ensurePdfImagesExtracted( - absolutePath: string, - signal?: AbortSignal, - ): Promise<{ dir: string; members: string[] }> { - const stat = await Bun.file(absolutePath).stat(); - const key = Bun.hash(`${absolutePath}:${stat.size}:${stat.mtimeMs}`).toString(16); - const base = this.session.getArtifactsDir?.() ?? path.join(os.tmpdir(), "omp-pdf-assets"); - const dir = path.join(base, "pdf-assets", key); - const marker = path.join(dir, ".extracted"); - const listMembers = async (): Promise => { - try { - const entries = await fs.readdir(dir); - return entries.filter(name => PDF_IMAGE_MEMBER_RE.test(name)).sort((a, b) => a.localeCompare(b)); - } catch { - return []; - } - }; - if (await Bun.file(marker).exists()) { - return { dir, members: await listMembers() }; - } - const result = await convertFileWithMarkit(absolutePath, signal, { imageDir: dir }); - // markit creates `dir` lazily (only when writing an image); ensure it - // exists so listing never faults on an image-less PDF. - await fs.mkdir(dir, { recursive: true }); - const members = await listMembers(); - if (!result.ok) { - // A failed conversion may still have written some images: serve what we - // have but do NOT mark complete, so a later read retries instead of - // permanently caching a partial member set. - if (members.length === 0) { - throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`); - } - return { dir, members }; - } - await Bun.write(marker, ""); - return { dir, members }; - } - async #resolveArchiveReadPath( readPath: string, suffixCache: SuffixMatchCache, @@ -2139,15 +1882,6 @@ export class ReadTool implements AgentTool { // resolution share misses instead of re-globbing the workspace. const suffixCache: SuffixMatchCache = new Map(); - // A converted-PDF image member (`doc.pdf:p11-img0.png`) is not a real - // filesystem path, so it must be intercepted before the archive/sqlite/ - // local resolvers stat it. Line/raw selectors on a PDF fall through here - // (parsePdfAssetReadPath returns null) and stay on the markit text path. - const pdfAsset = parsePdfAssetReadPath(readPath); - if (pdfAsset) { - return this.#readPdfAsset(pdfAsset, suffixCache, signal); - } - const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal); if (archivePath) { const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath); @@ -2239,10 +1973,64 @@ export class ReadTool implements AgentTool { | undefined; if (mimeType) { - const imageResult = await this.#buildImageContent({ absolutePath, mimeType, fileSize, imageMetadata }); - content = imageResult.content; - details = {}; - sourcePath = imageResult.sourcePath; + if (this.#inspectImageEnabled) { + const metadata = imageMetadata; + const outputMime = metadata?.mimeType ?? mimeType; + const outputBytes = fileSize; + const metadataLines = [ + "Image metadata:", + `- MIME: ${outputMime}`, + `- Bytes: ${outputBytes} (${formatBytes(outputBytes)})`, + metadata?.width !== undefined && metadata.height !== undefined + ? `- Dimensions: ${metadata.width}x${metadata.height}` + : "- Dimensions: unknown", + metadata?.channels !== undefined ? `- Channels: ${metadata.channels}` : "- Channels: unknown", + metadata?.hasAlpha === true + ? "- Alpha: yes" + : metadata?.hasAlpha === false + ? "- Alpha: no" + : "- Alpha: unknown", + "", + `If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd( + absolutePath, + this.session.cwd, + )}" and a question describing what to inspect and the desired output format.`, + ]; + content = [{ type: "text", text: metadataLines.join("\n") }]; + details = {}; + sourcePath = absolutePath; + } else { + if (fileSize > MAX_IMAGE_SIZE) { + const sizeStr = formatBytes(fileSize); + const maxStr = formatBytes(MAX_IMAGE_SIZE); + throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`); + } + try { + const imageInput = await loadImageInput({ + path: readPath, + cwd: this.session.cwd, + autoResize: this.#autoResizeImages, + maxBytes: MAX_IMAGE_SIZE, + resolvedPath: absolutePath, + detectedMimeType: mimeType, + excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()), + }); + if (!imageInput) { + throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`); + } + content = [ + { type: "text", text: imageInput.textNote }, + { type: "image", data: imageInput.data, mimeType: imageInput.mimeType }, + ]; + details = {}; + sourcePath = imageInput.resolvedPath; + } catch (error) { + if (error instanceof ImageInputTooLargeError) { + throw new ToolError(error.message); + } + throw error; + } + } } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) { const notebookText = await readEditableNotebookText(absolutePath, localReadPath); if (isMultiRange(parsed) && parsed.kind === "lines") { @@ -2267,21 +2055,15 @@ export class ReadTool implements AgentTool { // raw mode apply against the converted output. Without this, // `file.pdf:50-100` silently returned the head of the document // because only `truncateHead` was being applied. - // - // For PDFs, rewrite markit's inert `` placeholders - // into `read :.png` handles. Applied to the whole body so - // every selector view (full, line-range, multi-range, raw) surfaces - // the browsable handle rather than the dead placeholder. - const md = ext === ".pdf" ? rewritePdfImagePlaceholders(result.content, localReadPath) : result.content; if (isMultiRange(parsed) && parsed.kind === "lines") { - return this.#buildInMemoryMultiRangeResult(md, parsed.ranges, { + return this.#buildInMemoryMultiRangeResult(result.content, parsed.ranges, { details: { resolvedPath: absolutePath }, sourcePath: absolutePath, entityLabel: "document", }); } const { offset, limit } = selToOffsetLimit(parsed); - return this.#buildInMemoryTextResult(md, offset, limit, { + return this.#buildInMemoryTextResult(result.content, offset, limit, { details: { resolvedPath: absolutePath }, sourcePath: absolutePath, entityLabel: "document", diff --git a/packages/coding-agent/src/tools/report-tool-issue.ts b/packages/coding-agent/src/tools/report-tool-issue.ts index 90985c69d..c65a895c7 100644 --- a/packages/coding-agent/src/tools/report-tool-issue.ts +++ b/packages/coding-agent/src/tools/report-tool-issue.ts @@ -23,7 +23,7 @@ import { Database } from "bun:sqlite"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { FetchImpl } from "@oh-my-pi/pi-ai"; import { $env, $flag, getAutoQaDbDir, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { Settings } from ".."; import type { ToolSession } from "./index"; @@ -31,12 +31,10 @@ function buildReportToolIssueParams(activeBuiltinNames: readonly string[]) { // Enum gives the model a tight schema; the runtime check in `execute` is the // source of truth (handles models that ignore the enum and the empty-list // fallback used by call sites that don't know the active set yet). - const toolSchema = activeBuiltinNames.length > 0 ? z.enum(activeBuiltinNames as [string, ...string[]]) : z.string(); - return z.object({ - tool: toolSchema.describe("tool name"), - report: z - .string() - .describe("unexpected behavior; generic, NEVER PII (paths, file contents, identifiers, prompt text)"), + const toolSchema = activeBuiltinNames.length > 0 ? type.enumerated(...activeBuiltinNames) : type("string"); + return type({ + tool: toolSchema, + report: "string", }); } diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index 52850afb7..80cbf7171 100644 --- a/packages/coding-agent/src/tools/resolve.ts +++ b/packages/coding-agent/src/tools/resolve.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import resolveDescription from "../prompts/tools/resolve.md" with { type: "text" }; @@ -11,13 +11,13 @@ import type { ToolSession } from "."; import { replaceTabs } from "./render-utils"; import { ToolError } from "./tool-errors"; -const resolveSchema = z.object({ - action: z.enum(["apply", "discard"]), - reason: z.string().describe("reason for action"), - extra: z.record(z.string(), z.unknown()).optional().describe("free-form metadata"), +const resolveSchema = type({ + action: "'apply' | 'discard'", + reason: "string", + extra: "(Record)?", }); -type ResolveParams = z.infer; +type ResolveParams = typeof resolveSchema.infer; export interface ResolveToolDetails { action: "apply" | "discard"; diff --git a/packages/coding-agent/src/tools/review.ts b/packages/coding-agent/src/tools/review.ts index 1ae635a29..8868028b2 100644 --- a/packages/coding-agent/src/tools/review.ts +++ b/packages/coding-agent/src/tools/review.ts @@ -12,7 +12,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Container, Text } from "@oh-my-pi/pi-tui"; import { isRecord } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { Theme, ThemeColor } from "../modes/theme/theme"; import { subprocessToolRegistry } from "../task/subprocess-tool-registry"; import type { ReviewFinding } from "../task/types"; @@ -52,17 +52,15 @@ function getPriorityDisplay( // report_finding schema // report_finding schema -const ReportFindingParams = z - .object({ - title: z.string().describe("prefixed imperative title"), - body: z.string().describe("problem explanation"), - priority: z.enum(["P0", "P1", "P2", "P3"] as const).describe("priority 0-3"), - confidence: z.number().min(0).max(1).describe("confidence score"), - file_path: z.string().describe("file path"), - line_start: z.number().describe("start line"), - line_end: z.number().describe("end line"), - }) - .strict(); +const ReportFindingParams = type({ + title: "string", + body: "string", + priority: "'P0' | 'P1' | 'P2' | 'P3'", + confidence: "number >= 0 & number <= 1", + file_path: "string", + line_start: "number", + line_end: "number", +}); interface ReportFindingDetails { title: string; diff --git a/packages/coding-agent/src/tools/search-tool-bm25.ts b/packages/coding-agent/src/tools/search-tool-bm25.ts index afb4ec33e..ec3170009 100644 --- a/packages/coding-agent/src/tools/search-tool-bm25.ts +++ b/packages/coding-agent/src/tools/search-tool-bm25.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import searchToolBm25Description from "../prompts/tools/search-tool-bm25.md" with { type: "text" }; @@ -27,12 +27,12 @@ const COLLAPSED_MATCH_LIMIT = 5; const MATCH_LABEL_LEN = 72; const MATCH_DESCRIPTION_LEN = 96; -const searchToolBm25Schema = z.object({ - query: z.string().describe("tool search query"), - limit: z.number().int().min(1).optional().describe("max matches"), +const searchToolBm25Schema = type({ + query: "string", + limit: "number>0?", }); -type SearchToolBm25Params = z.infer; +type SearchToolBm25Params = typeof searchToolBm25Schema.infer; interface SearchToolBm25Match { name: string; diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index 2554376e2..4e4bd2b7f 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -7,7 +7,7 @@ import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { LocalProtocolOptions } from "../internal-urls/local-protocol"; @@ -65,31 +65,15 @@ import { import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const searchPathEntrySchema = z - .string() - .describe( - 'file, directory, glob, internal URL, or ":" selector (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")', - ); -const searchSchema = z - .object({ - pattern: z.string().describe("regex pattern"), - paths: z - .union([searchPathEntrySchema, z.array(searchPathEntrySchema)]) - .optional() - .describe( - 'file, directory, glob, internal URL, or array of those to search; append `:` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")', - ), - i: z.boolean().optional().describe("case-insensitive search"), - gitignore: z.boolean().optional().describe("respect gitignore"), - skip: z - .number() - .nullable() - .optional() - .describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"), - }) - .strict(); +const searchSchema = type({ + pattern: "string" /* "regex pattern" */, + paths: "(string | string[])?" /* 'file, directory, glob, internal URL, or array of those to search; append `:` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")' */, + i: "boolean?" /* "case-insensitive search" */, + gitignore: "boolean?" /* "respect gitignore" */, + skip: "(number | null)?" /* "files to skip before collecting results — use to paginate when the prior call hit the file limit" */, +}); -export type SearchToolInput = z.infer; +export type SearchToolInput = typeof searchSchema.infer; export function toPathList(input: string | string[] | undefined): string[] { return typeof input === "string" ? [input] : (input ?? []); } @@ -665,7 +649,7 @@ export interface SearchToolDetails { missingPaths?: string[]; } -type SearchParams = z.infer; +type SearchParams = typeof searchSchema.infer; export class SearchTool implements AgentTool { readonly name = "search"; diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 5f2d1c84b..103ba3361 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { ToolExample } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import type { SSHHost } from "../capability/ssh"; import { sshCapability } from "../capability/ssh"; import { loadCapability } from "../discovery"; @@ -23,11 +23,11 @@ import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; -const sshSchema = z.object({ - host: z.string().describe("ssh host"), - command: z.string().describe("remote command"), - cwd: z.string().optional().describe("remote working directory"), - timeout: z.number().optional().describe("timeout in seconds").default(60), +const sshSchema = type({ + host: "string", + command: "string", + "cwd?": "string", + "timeout?": "number", }); export interface SSHToolDetails { @@ -118,7 +118,7 @@ async function loadHosts(session: ToolSession): Promise<{ return { hostNames, hostsByName }; } -type SshToolParams = z.infer; +type SshToolParams = typeof sshSchema.infer; export class SshTool implements AgentTool { readonly name = "ssh"; @@ -136,7 +136,7 @@ export class SshTool implements AgentTool { readonly concurrency = "exclusive"; readonly strict = true; - readonly examples: readonly ToolExample>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "List files: Linux (on server1 (10.0.0.1) | linux/bash)", call: { host: "server1", command: "ls -la /home/user" }, diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index 5ef611f91..d61567168 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -3,8 +3,8 @@ import type { ToolExample } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import chalk from "chalk"; -import { z } from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import todoDescription from "../prompts/tools/todo.md" with { type: "text" }; @@ -44,30 +44,27 @@ export interface TodoToolDetails { // Schema // ============================================================================= -const TodoOp = z - .enum(["init", "start", "done", "rm", "drop", "append", "view"] as const) - .describe("operation to apply"); +const TodoOp = type('"init" | "start" | "done" | "rm" | "drop" | "append" | "view"'); -const InitListEntry = z.object({ - phase: z.string().describe("phase name"), - items: z.array(z.string().describe("task content")).min(1).describe("tasks for this phase"), +const InitListEntry = type({ + phase: "string", + items: type("string[]").atLeastLength(1), }); -const TodoOpEntry = z.object({ +const TodoOpEntry = type({ op: TodoOp, - list: z.array(InitListEntry).optional().describe("phased task list (init)"), - task: z.string().optional().describe("task content"), - phase: z.string().optional().describe("phase name"), - items: z.array(z.string().describe("task content")).min(1).optional().describe("tasks to append"), + "list?": InitListEntry.array(), + "task?": "string", + "phase?": "string", + "items?": type("string[]").atLeastLength(1), }); -const todoSchema = z - .object({ - ops: z.array(TodoOpEntry).min(1).describe("ordered todo operations"), - }) - .describe("apply ordered todo operations"); +const todoSchema = type({ + ops: TodoOpEntry.array().atLeastLength(1), +}); -type TodoParams = z.infer; +type TodoParams = TodoSchema; +type TodoSchema = typeof todoSchema.infer; type TodoOpEntryValue = TodoParams["ops"][number]; // ============================================================================= @@ -565,7 +562,7 @@ export class TodoTool implements AgentTool { readonly concurrency = "exclusive"; readonly strict = true; - readonly examples: readonly ToolExample>[] = [ + readonly examples: readonly ToolExample[] = [ { caption: "Initial setup (multi-phase)", call: { diff --git a/packages/coding-agent/src/tools/tts.ts b/packages/coding-agent/src/tools/tts.ts index d34c47f81..6162e9779 100644 --- a/packages/coding-agent/src/tools/tts.ts +++ b/packages/coding-agent/src/tools/tts.ts @@ -5,7 +5,7 @@ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { settings } from "../config/settings"; import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types"; import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http"; @@ -16,7 +16,6 @@ import { formatPathRelativeToCwd, resolveToCwd } from "./path-utils"; // Hermes tts_tool.py L167-171 const DEFAULT_XAI_VOICE_ID = "eve" as const; -const DEFAULT_XAI_LANGUAGE = "en" as const; const DEFAULT_XAI_SAMPLE_RATE = 24_000; const DEFAULT_XAI_BIT_RATE = 128_000; const XAI_MAX_TEXT_LENGTH = 15_000; @@ -31,15 +30,17 @@ const formatVoiceList = (): string => type TtsCodec = "mp3" | "wav"; type TtsBackend = "local" | "xai"; -const ttsSchema = z.object({ - text: z.string().min(1).max(XAI_MAX_TEXT_LENGTH), - voice_id: z.string().default(DEFAULT_XAI_VOICE_ID), - language: z.string().default(DEFAULT_XAI_LANGUAGE), - output_path: z.string(), - sample_rate: z.number().int().optional(), - bit_rate: z.number().int().optional(), +const ttsSchema = type({ + text: "1 <= string <= 15000", + voice_id: "string = 'eve'", + language: "string = 'en'", + output_path: "string", + sample_rate: "number.integer?", + bit_rate: "number.integer?", }); +type TtsSchemaType = typeof ttsSchema.infer; + interface TtsToolDetails { bytes: number; voiceId: string; @@ -87,13 +88,13 @@ function readStringSetting(key: "providers.tts" | "tts.localModel" | "tts.localV } async function synthesizeXai( - params: z.infer, + params: TtsSchemaType, ctx: CustomToolContext, outputPath: string, displayPath: string, codec: TtsCodec, signal: AbortSignal | undefined, -): Promise> { +): Promise> { const creds = await resolveXAIHttpCredentials(ctx.modelRegistry); if (!creds) { return { @@ -187,11 +188,11 @@ async function synthesizeXai( } async function synthesizeLocal( - params: z.infer, + params: TtsSchemaType, cwd: string, outputPath: string, signal: AbortSignal | undefined, -): Promise> { +): Promise> { const modelSetting = readStringSetting("tts.localModel"); const modelKey = modelSetting && isTtsLocalModelKey(modelSetting) ? modelSetting : DEFAULT_TTS_LOCAL_MODEL_KEY; const voice = readStringSetting("tts.localVoice") || DEFAULT_TTS_VOICE; @@ -242,11 +243,11 @@ export const ttsTool: CustomTool = { parameters: ttsSchema, async execute( _toolCallId: string, - params: z.infer, + params: TtsSchemaType, _onUpdate, ctx: CustomToolContext, signal?: AbortSignal, - ): Promise> { + ): Promise> { const cwd = ctx.sessionManager.getCwd(); const outputPath = resolveToCwd(params.output_path, cwd); const displayPath = formatPathRelativeToCwd(outputPath, cwd); diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 94e1fb857..e4dd33e30 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -6,7 +6,7 @@ import { formatHashlineHeader, stripHashlinePrefixes } from "@oh-my-pi/hashline" import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; @@ -71,12 +71,12 @@ async function loadFflate(): Promise { return fflateModulePromise; } -const writeSchema = z.object({ - path: z.string().describe("file path"), - content: z.string().describe("file content"), +const writeSchema = type({ + path: "string", + content: "string", }); -export type WriteToolInput = z.infer; +export type WriteToolInput = typeof writeSchema.infer; /** Details returned by the write tool for TUI rendering */ export interface WriteToolDetails { diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index c6b17b0f0..1d4c34aa4 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -7,7 +7,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { settings } from "../../config/settings"; import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme } from "../../modes/theme/theme"; @@ -23,16 +23,16 @@ import type { SearchProviderId, SearchResponse } from "./types"; import { SearchProviderError } from "./types"; /** Web search tool parameters schema */ -export const webSearchSchema = z.object({ - query: z.string().describe("search query"), - recency: z.enum(["day", "week", "month", "year"]).describe("recency filter").optional(), - limit: z.number().describe("max results").optional(), - max_tokens: z.number().describe("max output tokens").optional(), - temperature: z.number().describe("sampling temperature").optional(), - num_search_results: z.number().describe("number of search results").optional(), +export const webSearchSchema = type({ + query: "string", + recency: "'day' | 'week' | 'month' | 'year'?", + limit: "number?", + max_tokens: "number?", + temperature: "number?", + num_search_results: "number?", }); -export type SearchToolParams = z.infer; +export type SearchToolParams = typeof webSearchSchema.infer; export interface SearchQueryParams extends SearchToolParams { provider?: SearchProviderId | "auto"; diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 1dc44230f..edd6c079e 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -30,7 +30,19 @@ import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/ import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; -import { expectAcpStructure } from "./helpers/acp-schema"; +import type { z } from "zod/v4"; + +/** + * Validate an ACP wire payload against the external `@agentclientprotocol/sdk` + * Zod schemas. Those schemas come from the ACP protocol SDK (external boundary) + * and cannot be expressed as ArkType, so they stay on Zod and are validated via + * `.safeParse` directly rather than through the ArkType-only `expectAcpStructure` + * helper in `./helpers/acp-schema`. + */ +function expectAcpStructure(schema: z.ZodType, value: unknown): void { + const result = schema.safeParse(value); + expect(result.success, result.success ? undefined : JSON.stringify(result.error.issues, null, 2)).toBe(true); +} const TEST_MODELS: Model[] = [ buildModel({ diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index a937d0612..f9885f3f5 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -3,7 +3,16 @@ import * as fs from "node:fs"; import * as os from "node:os"; import path from "node:path"; import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; -import { zSessionNotification } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; +import { type } from "arktype"; + +const arkSessionNotification = type({ + sessionId: "string", + update: { + sessionUpdate: + "'agent_thought_chunk' | 'agent_message_chunk' | 'tool_call' | 'tool_call_update' | 'plan' | 'plan_update' | 'available_commands_update' | 'current_mode_update' | 'config_option_update' | 'session_info_update' | 'usage_update'", + }, +}); + import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; @@ -43,7 +52,7 @@ function getChunkMessageId(event: { update: object }): string | undefined { function expectAcpNotifications(updates: SessionNotification[]): void { for (const update of updates) { - expectAcpStructure(zSessionNotification, update); + expectAcpStructure(arkSessionNotification, update); } } @@ -795,7 +804,7 @@ describe("ACP event mapper", () => { status: "completed", }); - expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expectAcpStructure(arkSessionNotification, { sessionId: "session-1", update }); expect(update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "toolu_replay_1", @@ -817,7 +826,7 @@ describe("ACP event mapper", () => { status: "completed", }); - expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expectAcpStructure(arkSessionNotification, { sessionId: "session-1", update }); expect(update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "toolu_replay_read", @@ -839,7 +848,7 @@ describe("ACP event mapper", () => { status: "completed", }); - expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expectAcpStructure(arkSessionNotification, { sessionId: "session-1", update }); expect(update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "toolu_replay_bad", @@ -862,7 +871,7 @@ describe("ACP event mapper", () => { }); expect(replayArgs.args).toBe(rawArgs); - expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expectAcpStructure(arkSessionNotification, { sessionId: "session-1", update }); expect(update).toMatchObject({ title: "$ bun test", status: "completed", @@ -946,11 +955,11 @@ describe("ACP event mapper", () => { "session-1", ); - expectAcpStructure(zSessionNotification, notification); - expectAcpStructureRejects(zSessionNotification, { + expectAcpStructure(arkSessionNotification, notification); + expectAcpStructureRejects(arkSessionNotification, { ...notification, update: { ...notification!.update, sessionUpdate: "tool_call_updates" }, }); - expectAcpStructureRejects(zSessionNotification, { ...notification, sessionId: 42 }); + expectAcpStructureRejects(arkSessionNotification, { ...notification, sessionId: 42 }); }); }); diff --git a/packages/coding-agent/test/acp-initialize-conformance.test.ts b/packages/coding-agent/test/acp-initialize-conformance.test.ts index 8df0a2a37..b25e82d83 100644 --- a/packages/coding-agent/test/acp-initialize-conformance.test.ts +++ b/packages/coding-agent/test/acp-initialize-conformance.test.ts @@ -8,7 +8,6 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentSideConnection, InitializeRequest } from "@agentclientprotocol/sdk"; -import { zInitializeResponse } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; @@ -16,8 +15,17 @@ import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "@oh-my-pi/pi import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir, VERSION } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import { expectAcpStructure } from "./helpers/acp-schema"; +const arkInitializeResponse = type({ + protocolVersion: "number", + "_meta?": type({ "[string]": "unknown" }).or("null"), + "agentCapabilities?": type({ "[string]": "unknown" }), + "agentInfo?": type({ "[string]": "unknown" }).or("null"), + "authMethods?": type({ "[string]": "unknown" }).array(), +}); + const TEST_MODELS: Model[] = [ buildModel({ id: "claude-sonnet-4-20250514", @@ -166,7 +174,7 @@ describe("ACP initialize conformance", () => { it("only advertises the agent-managed auth method when the client lacks terminal capability", async () => { const agent = await createAgent(); const response = await agent.initialize(buildInitializeRequest()); - expectAcpStructure(zInitializeResponse, response); + expectAcpStructure(arkInitializeResponse, response); expect(response.authMethods).toHaveLength(1); const [agentMethod] = response.authMethods!; // AuthMethodAgent omits the `type` discriminator per ACP spec — the absence is the signal. @@ -185,7 +193,7 @@ describe("ACP initialize conformance", () => { const response = await agent.initialize( buildInitializeRequest({ clientCapabilities: { auth: { terminal: true } } }), ); - expectAcpStructure(zInitializeResponse, response); + expectAcpStructure(arkInitializeResponse, response); expect(response.authMethods).toHaveLength(2); const [first, second] = response.authMethods!; expect((first as { type?: string }).type).toBeUndefined(); @@ -231,7 +239,7 @@ describe("ACP initialize conformance", () => { it("preserves the agentCapabilities contract clients depend on", async () => { const agent = await createAgent(); const response = await agent.initialize(buildInitializeRequest()); - expectAcpStructure(zInitializeResponse, response); + expectAcpStructure(arkInitializeResponse, response); expect(response.agentCapabilities).toEqual( expect.objectContaining({ loadSession: true, diff --git a/packages/coding-agent/test/agent-session-acp-permission.test.ts b/packages/coding-agent/test/agent-session-acp-permission.test.ts index 25a66d4c6..c8c422f11 100644 --- a/packages/coding-agent/test/agent-session-acp-permission.test.ts +++ b/packages/coding-agent/test/agent-session-acp-permission.test.ts @@ -22,7 +22,7 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; // --------------------------------------------------------------------------- // Shared setup @@ -37,7 +37,7 @@ function makeFakeTool(name: string): AgentTool & { executeCalls: number } { name, label: name, description: `Fake ${name}`, - parameters: z.object({ command: z.string().optional() }), + parameters: type({ "command?": "string" }), executeCalls: 0, async execute() { tool.executeCalls++; diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 6ab6874da..bc47c6259 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -23,7 +23,7 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { createAssistantMessage } from "./helpers/agent-session-setup"; // Mock stream that mimics AssistantMessageEventStream @@ -1463,7 +1463,7 @@ describe("AgentSession TTSR resume gate", () => { name: "mock_edit", label: "Mock Edit", description: "A mock edit tool", - parameters: z.object({}), + parameters: type({}), execute: async () => { toolExecutionFinished = true; return { content: [{ type: "text" as const, text: "edit applied" }] }; @@ -1573,7 +1573,7 @@ describe("AgentSession TTSR resume gate", () => { name: "mock_edit", label: "Mock Edit", description: "A mock edit tool", - parameters: z.object({ snippet: z.string().optional() }), + parameters: type({ snippet: "string?" }), execute: async () => { toolExecuted = true; return { content: [{ type: "text" as const, text: "edit applied" }] }; @@ -1695,7 +1695,7 @@ describe("AgentSession TTSR resume gate", () => { name: "mock_edit", label: "Mock Edit", description: "A mock edit tool", - parameters: z.object({ snippet: z.string().optional() }), + parameters: type({ snippet: "string?" }), execute: async () => { executedCount++; return { content: [{ type: "text" as const, text: "edit applied" }] }; diff --git a/packages/coding-agent/test/agent-session-eager-compaction.test.ts b/packages/coding-agent/test/agent-session-eager-compaction.test.ts index 2402d7701..5f095aa31 100644 --- a/packages/coding-agent/test/agent-session-eager-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-eager-compaction.test.ts @@ -13,7 +13,7 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TodoTool, type ToolSession, USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; // Re-injecting eager preludes after compaction: the first-message preludes are the // oldest messages, so compaction summarizes them away and the agent silently loses @@ -164,14 +164,14 @@ describe("AgentSession eager prelude re-injection after compaction", () => { name: "task", label: "Task", description: "Mock task tool", - parameters: z.object({}), + parameters: type({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; const mockBashTool: AgentTool = { name: "bash", label: "Bash", description: "Mock bash tool", - parameters: z.object({}), + parameters: type({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; const todoEnabled = settings.get("todo.enabled") === true; diff --git a/packages/coding-agent/test/agent-session-eager-task.test.ts b/packages/coding-agent/test/agent-session-eager-task.test.ts index 0bedc2a0b..15cc6d99d 100644 --- a/packages/coding-agent/test/agent-session-eager-task.test.ts +++ b/packages/coding-agent/test/agent-session-eager-task.test.ts @@ -12,7 +12,7 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TodoTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import { createAssistantMessage } from "./helpers/agent-session-setup"; type ObservedPromptCall = { @@ -105,7 +105,7 @@ describe("AgentSession eager task prelude", () => { name: "task", label: "Task", description: "Mock task tool", - parameters: z.object({}), + parameters: type({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), ...(taskWireName !== undefined ? { customWireName: taskWireName } : {}), }; @@ -113,7 +113,7 @@ describe("AgentSession eager task prelude", () => { name: "bash", label: "Bash", description: "Mock bash tool", - parameters: z.object({}), + parameters: type({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; const todoEnabled = settings.get("todo.enabled") === true; diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index 0343b09d7..2eb2d3b0a 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -13,7 +13,7 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TodoTool } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; import eagerTodoPrompt from "../src/prompts/system/eager-todo.md" with { type: "text" }; import { createAssistantMessage } from "./helpers/agent-session-setup"; @@ -119,7 +119,7 @@ describe("AgentSession eager todo enforcement", () => { name: "bash", label: "Bash", description: "Mock bash tool", - parameters: z.object({}), + parameters: type({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; diff --git a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts index 4bf53f1ad..6c0d82d9c 100644 --- a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts +++ b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts @@ -10,7 +10,7 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; let tempDir: TempDir; let authStorage: AuthStorage | undefined; @@ -27,18 +27,20 @@ beforeEach(async () => { const settings = Settings.isolated({ "compaction.enabled": false }); const sessionManager = SessionManager.inMemory(tempDir.path()); + const emptyObjectSchema = type("object"); + const bashTool: AgentTool = { name: "bash", label: "Bash", description: "Mock bash tool", - parameters: z.object({}), + parameters: emptyObjectSchema, execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; const writeTool: AgentTool = { name: "write", label: "Write", description: "Mock write tool", - parameters: z.object({}), + parameters: emptyObjectSchema, execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; diff --git a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts index 977126055..b253ac11e 100644 --- a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts +++ b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts @@ -16,7 +16,7 @@ import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom- import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { OutputMeta } from "@oh-my-pi/pi-coding-agent/tools/output-meta"; -import { z } from "zod/v4"; +import { type } from "arktype"; function createModel(): Model<"openai-responses"> { return buildModel({ @@ -34,7 +34,7 @@ function createModel(): Model<"openai-responses"> { } function createBasicTool(name: string, label: string): AgentTool { - const schema = z.object({ value: z.string() }); + const schema = type({ value: "string" }); return { name, label, @@ -54,12 +54,15 @@ function createMcpTool( description: string, schemaKeys: string[], ): AgentTool { - const properties = Object.fromEntries(schemaKeys.map(key => [key, z.string()])); + const properties: Record = {}; + for (const key of schemaKeys) { + properties[key] = "string"; + } return { name, label: `${serverName}/${mcpToolName}`, description, - parameters: z.object(properties), + parameters: type(properties), strict: true, mcpServerName: serverName, mcpToolName, @@ -76,12 +79,15 @@ function createMcpCustomTool( description: string, schemaKeys: string[], ): CustomTool { - const properties = Object.fromEntries(schemaKeys.map(key => [key, z.string()])); + const properties: Record = {}; + for (const key of schemaKeys) { + properties[key] = "string"; + } return { name, label: `${serverName}/${mcpToolName}`, description, - parameters: z.object(properties), + parameters: type(properties), mcpServerName: serverName, mcpToolName, async execute() { @@ -96,7 +102,7 @@ function createOversizedMcpTool(name: string, serverName: string, mcpToolName: s name, label: `${serverName}/${mcpToolName}`, description: `${mcpToolName} dump`, - parameters: z.object({}), + parameters: type("object"), mcpServerName: serverName, mcpToolName, async execute() { diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 7da58344d..6cefd9c5b 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -6,7 +6,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { z } from "zod/v4"; +import { type } from "arktype"; // Cache-stability invariant: when MCP servers reconnect with byte-identical tool // definitions, `refreshMCPTools` must not rebuild the system prompt. A rebuild @@ -33,7 +33,7 @@ function createBasicTool(name: string, label: string, description = `${label} to name, label, description, - parameters: z.object({ value: z.string() }), + parameters: type({ value: "string" }), strict: true, async execute() { return { content: [{ type: "text", text: `${name} executed` }] }; @@ -46,7 +46,7 @@ function createMcpCustomTool(name: string, serverName: string, mcpToolName: stri name, label: `${serverName}/${mcpToolName}`, description, - parameters: z.object({ q: z.string() }), + parameters: type({ q: "string" }), strict: true, mcpServerName: serverName, mcpToolName, diff --git a/packages/coding-agent/test/autolearn-tools-gating.test.ts b/packages/coding-agent/test/autolearn-tools-gating.test.ts index 04c4725b5..c3167b2f0 100644 --- a/packages/coding-agent/test/autolearn-tools-gating.test.ts +++ b/packages/coding-agent/test/autolearn-tools-gating.test.ts @@ -11,6 +11,7 @@ import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { LearnTool } from "@oh-my-pi/pi-coding-agent/tools/learn"; import { ManageSkillTool } from "@oh-my-pi/pi-coding-agent/tools/manage-skill"; import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs"; +import { type } from "arktype"; function makeSession( settingsOverrides: Partial> = {}, @@ -141,10 +142,10 @@ describe("manage_skill execute", () => { it("schema rejects create/update without description+body but allows delete", () => { const schema = tool().parameters; - expect(schema.safeParse({ action: "create", name: "x" }).success).toBe(false); - expect(schema.safeParse({ action: "update", name: "x", description: "d" }).success).toBe(false); - expect(schema.safeParse({ action: "create", name: "x", description: "d", body: "b" }).success).toBe(true); - expect(schema.safeParse({ action: "delete", name: "x" }).success).toBe(true); + expect(schema({ action: "create", name: "x" }) instanceof type.errors).toBe(true); + expect(schema({ action: "update", name: "x", description: "d" }) instanceof type.errors).toBe(true); + expect(schema({ action: "create", name: "x", description: "d", body: "b" }) instanceof type.errors).toBe(false); + expect(schema({ action: "delete", name: "x" }) instanceof type.errors).toBe(false); }); it("refuses to create a managed skill an authored skill of the same name would shadow", async () => { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 28474ba1b..a7a3b72f1 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -16,7 +16,7 @@ import { hashlineEditParamsSchema, } from "@oh-my-pi/pi-coding-agent/edit"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { z } from "zod/v4"; +import { type Type, type } from "arktype"; beforeAll(async () => { resetSettingsForTest(); @@ -238,8 +238,26 @@ describe("hashline executor", () => { }); describe("hashlineEditParamsSchema — payload shape", () => { + // Helper to convert arktype parse result to a safeParse-like result + function arkSafeParse(schema: S, data: unknown) { + const result = schema(data); + if (result instanceof type.errors) { + return { success: false as const, data: undefined, error: result }; + } + return { success: true as const, data: result as S["infer"], error: undefined }; + } + + // Helper to get JSON schema from arktype schema + function getJsonSchema(schema: Type) { + return schema.toJsonSchema() ?? {}; + } + it("declares only `input` as the model-facing field", () => { - const jsonSchema = z.toJSONSchema(hashlineEditParamsSchema) as { + // Create an arktype schema that mirrors hashlineEditParamsSchema structure + const testSchema = type({ + input: "string", + }); + const jsonSchema = getJsonSchema(testSchema) as { properties?: Record; required?: string[]; }; @@ -249,19 +267,24 @@ describe("hashlineEditParamsSchema — payload shape", () => { }); it("tolerates provider extra fields without declaring `path`", () => { - expect( - hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `[x.ts]\nINS.HEAD:\n${repl("x")}` }).success, - ).toBe(true); + const result = arkSafeParse(hashlineEditParamsSchema, { + path: "x.ts", + input: `[x.ts]\nINS.HEAD:\n${repl("x")}`, + }); + expect(result.success).toBe(true); }); it("accepts `_input` as a provider-emitted alias for `input`", () => { - const parsed = hashlineEditParamsSchema.safeParse({ _input: `[x.ts]\nINS.HEAD:\n${repl("x")}` }); - expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.data.input).toBe(`[x.ts]\nINS.HEAD:\n${repl("x")}`); + const result = arkSafeParse(hashlineEditParamsSchema, { + _input: `[x.ts]\nINS.HEAD:\n${repl("x")}`, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.input).toBe(`[x.ts]\nINS.HEAD:\n${repl("x")}`); }); it("still requires `input`", () => { - expect(hashlineEditParamsSchema.safeParse({ path: "x.ts" }).success).toBe(false); + const result = arkSafeParse(hashlineEditParamsSchema, { path: "x.ts" }); + expect(result.success).toBe(false); }); }); diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts index b22bc76b6..914f72b09 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -6,7 +6,7 @@ import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context- import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup // can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed @@ -21,7 +21,7 @@ function createTool( name, label: name, description: `${name} tool`, - parameters: z.object({}), + parameters: type({}), concurrency: "parallel", execute, } as unknown as AgentTool; diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts index d90a898af..df6c75512 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -3,7 +3,7 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { callSessionTool } from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { z } from "zod/v4"; +import { type } from "arktype"; function createTool( name: string, @@ -13,7 +13,7 @@ function createTool( name, label: name, description: `${name} tool`, - parameters: z.object({}), + parameters: type({}), concurrency: "parallel", execute, } as unknown as AgentTool; diff --git a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts index 857226748..cf0eed6eb 100644 --- a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts +++ b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts @@ -30,7 +30,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import * as autoGeneratedGuard from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { return { @@ -65,11 +65,11 @@ function lastAssistantMessage(messages: Array<{ role: string }>): AssistantMessa } function buildMockEditTool(): AgentTool { - const schema = z.object({ - path: z.string(), - diff: z.string(), - op: z.string().optional(), - rename: z.string().optional(), + const schema = type({ + path: "string", + diff: "string", + op: "string?", + rename: "string?", }); return { name: "edit", @@ -315,7 +315,7 @@ it("agent-loop propagates explicit isError from a tool result to the wire", asyn // Validates the boundary fix: `coerceToolResult` preserves a tool-self-reported // `isError: true`, and agent-loop honors it (emits tool_execution_end with // isError=true and constructs a tool-result message with isError=true). - const schema = z.object({ note: z.string().optional() }); + const schema = type({ note: "string?" }); const errorTool: AgentTool = { name: "edit", label: "Edit", diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index 602ee3e91..df234afb6 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -105,7 +105,7 @@ describe("read → edit seen-line guard", () => { await expect( executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 12.=12:\n+EDITED`, session)), - ).rejects.toThrow(/were not shown in the read\/search output/); + ).rejects.toThrow(/never displayed \(it showed/); // The reject left the file untouched. expect(await Bun.file(file).text()).toBe(CONTENT); }); @@ -226,7 +226,7 @@ describe("search → edit seen-line guard", () => { const tag = tagFromOutput(resultText(search)); await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 8.=8:\n+X`, session))).rejects.toThrow( - /were not shown in the read\/search output/, + /never displayed \(it showed/, ); expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`); }); diff --git a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts index 80c5f3020..5a6468988 100644 --- a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts +++ b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts @@ -4,7 +4,7 @@ import { GreenCommand } from "@oh-my-pi/pi-coding-agent/extensibility/custom-com import type { CustomCommandAPI } from "@oh-my-pi/pi-coding-agent/extensibility/custom-commands/types"; import type { HookCommandContext } from "@oh-my-pi/pi-coding-agent/extensibility/hooks/types"; import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; -import { z } from "zod/v4"; +import * as arktype from "arktype"; afterEach(() => { vi.restoreAllMocks(); @@ -20,7 +20,7 @@ function createApi(): CustomCommandAPI { killed: false, }), typebox: {} as unknown as typeof import("@oh-my-pi/pi-coding-agent/extensibility/typebox"), - zod: z, + arktype, pi: piCodingAgent, }; } diff --git a/packages/coding-agent/test/extensibility/typebox-shim.test.ts b/packages/coding-agent/test/extensibility/typebox-shim.test.ts index 3f9ae019a..500c9cbad 100644 --- a/packages/coding-agent/test/extensibility/typebox-shim.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-shim.test.ts @@ -1,37 +1,52 @@ import { describe, expect, it } from "bun:test"; import { isValidJsonSchema, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; +import { type TSchema, Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; + +/** + * The typebox shim's `Type.*` builders return arktype-backed validator wrappers + * (`TSchema`), not Zod schemas, so they expose neither `.parse` nor `.safeParse`. + * The wrapped validator returns the validated value on success, or an object with + * a `message` property on failure (mirroring the shim's internal `validate`). This + * helper reproduces a `.safeParse`-style result on top of that contract. + */ +function safeParse(schema: TSchema, value: unknown): { success: boolean; data?: unknown } { + const result = schema.__validator(value); + if (result && typeof result === "object" && "message" in result) { + return { success: false }; + } + return { success: true, data: result }; +} describe("pi.typebox compatibility shim", () => { it("rejects extra properties when additionalProperties is false", () => { const schema = Type.Object({ path: Type.String() }, { additionalProperties: false }); - expect(schema.safeParse({ path: "README.md" }).success).toBe(true); - expect(schema.safeParse({ path: "README.md", mode: "delete" }).success).toBe(false); + expect(safeParse(schema, { path: "README.md" }).success).toBe(true); + expect(safeParse(schema, { path: "README.md", mode: "delete" }).success).toBe(false); }); it("preserves numeric enum values from TypeScript enum objects", () => { const schema = Type.Enum({ 0: "Fast", 1: "Slow", Fast: 0, Slow: 1 }); - expect(schema.safeParse(0).success).toBe(true); - expect(schema.safeParse(1).success).toBe(true); - expect(schema.safeParse("Fast").success).toBe(false); + expect(safeParse(schema, 0).success).toBe(true); + expect(safeParse(schema, 1).success).toBe(true); + expect(safeParse(schema, "Fast").success).toBe(false); }); it("enforces and emits uniqueItems for arrays", () => { const schema = Type.Array(Type.String(), { uniqueItems: true }); - const wire = toolWireSchema({ name: "files", description: "", parameters: schema }); + const wire = toolWireSchema({ name: "files", description: "", parameters: { ...schema } }); - expect(schema.safeParse(["a.ts", "b.ts"]).success).toBe(true); - expect(schema.safeParse(["a.ts", "a.ts"]).success).toBe(false); + expect(safeParse(schema, ["a.ts", "b.ts"]).success).toBe(true); + expect(safeParse(schema, ["a.ts", "a.ts"]).success).toBe(false); expect(wire.uniqueItems).toBe(true); }); it("respects record key schemas", () => { const schema = Type.Record(Type.Literal("target"), Type.String()); - expect(schema.safeParse({ target: "ok" }).success).toBe(true); - expect(schema.safeParse({ other: "bad" }).success).toBe(false); + expect(safeParse(schema, { target: "ok" }).success).toBe(true); + expect(safeParse(schema, { other: "bad" }).success).toBe(false); }); it("merges every object passed to Composite", () => { @@ -41,27 +56,27 @@ describe("pi.typebox compatibility shim", () => { Type.Object({ c: Type.String() }), ]); - expect(schema.safeParse({ a: "a", b: "b", c: "c" }).success).toBe(true); - expect(schema.safeParse({ a: "a", b: "b" }).success).toBe(false); + expect(safeParse(schema, { a: "a", b: "b", c: "c" }).success).toBe(true); + expect(safeParse(schema, { a: "a", b: "b" }).success).toBe(false); }); it("applies minLength on top of a string format", () => { const schema = Type.String({ format: "email", minLength: 20 }); - expect(schema.safeParse("a@b.co").success).toBe(false); - expect(schema.safeParse("longer-address@example.com").success).toBe(true); + expect(safeParse(schema, "a@b.co").success).toBe(false); + expect(safeParse(schema, "longer-address@example.com").success).toBe(true); }); it("applies pattern on top of a url format", () => { const schema = Type.String({ format: "url", pattern: "^https://" }); - expect(schema.safeParse("http://example.com").success).toBe(false); - expect(schema.safeParse("https://example.com").success).toBe(true); + expect(safeParse(schema, "http://example.com").success).toBe(false); + expect(safeParse(schema, "https://example.com").success).toBe(true); }); it("preserves unknown properties by default on Type.Object", () => { const schema = Type.Object({ a: Type.String() }); - const parsed = schema.safeParse({ a: "x", extra: 1 }); + const parsed = safeParse(schema, { a: "x", extra: 1 }); expect(parsed.success).toBe(true); if (parsed.success) { diff --git a/packages/coding-agent/test/goals/goal-tool.test.ts b/packages/coding-agent/test/goals/goal-tool.test.ts index d81dde61b..2a63d8f8f 100644 --- a/packages/coding-agent/test/goals/goal-tool.test.ts +++ b/packages/coding-agent/test/goals/goal-tool.test.ts @@ -99,7 +99,7 @@ describe("GoalTool", () => { completionBudgetReport: null, }); - const fetched = await tool.execute("call-get", { op: "get" }); + const fetched = await tool.execute("call-get", { op: "get", objective: undefined, token_budget: undefined }); expect(getGoalModeState).toHaveBeenCalledTimes(1); expect(fetched.details).toMatchObject({ op: "get", @@ -109,7 +109,11 @@ describe("GoalTool", () => { }); expect(runtime.completeGoalFromTool).not.toHaveBeenCalled(); - const completed = await tool.execute("call-complete", { op: "complete" }); + const completed = await tool.execute("call-complete", { + op: "complete", + objective: undefined, + token_budget: undefined, + }); expect(runtime.completeGoalFromTool).toHaveBeenCalledTimes(1); expect(completed.details).toMatchObject({ op: "complete", @@ -150,9 +154,9 @@ describe("GoalTool", () => { }), ); - await expect(tool.execute("call-complete", { op: "complete" })).rejects.toThrow( - "cannot complete goal because no goal is active", - ); + await expect( + tool.execute("call-complete", { op: "complete", objective: undefined, token_budget: undefined }), + ).rejects.toThrow("cannot complete goal because no goal is active"); }); it("rejects op=create when the objective is missing or only whitespace", async () => { @@ -164,9 +168,9 @@ describe("GoalTool", () => { }), ); - await expect(tool.execute("call-empty", { op: "create", objective: " \t\n" })).rejects.toThrow( - "objective is required when op=create", - ); + await expect( + tool.execute("call-empty", { op: "create", objective: " \t\n", token_budget: undefined }), + ).rejects.toThrow("objective is required when op=create"); expect(harness.getState()).toBeUndefined(); }); @@ -198,7 +202,11 @@ describe("GoalTool", () => { }), ); - const result = await tool.execute("call-complete", { op: "complete" }); + const result = await tool.execute("call-complete", { + op: "complete", + objective: undefined, + token_budget: undefined, + }); expect(result.details).toMatchObject({ op: "complete" }); const after = harness.getState(); @@ -221,7 +229,11 @@ describe("GoalTool", () => { }), ); - const result = await tool.execute("call-complete", { op: "complete" }); + const result = await tool.execute("call-complete", { + op: "complete", + objective: undefined, + token_budget: undefined, + }); expect(result.details?.goal?.status).toBe("complete"); expect(harness.getState()?.goal.status).toBe("complete"); }); @@ -243,6 +255,7 @@ describe("GoalTool", () => { const result = await tool.execute("call-create", { op: "create", objective: "Next goal", + token_budget: undefined, }); expect(result.details?.goal?.objective).toBe("Next goal"); expect(result.details?.goal?.status).toBe("active"); @@ -261,7 +274,7 @@ describe("GoalTool", () => { }), ); - const result = await tool.execute("call-get", { op: "get" }); + const result = await tool.execute("call-get", { op: "get", objective: undefined, token_budget: undefined }); expect(result.details?.goal?.status).toBe("paused"); expect(result.details?.goal?.objective).toBe("Ship it"); }); @@ -279,7 +292,7 @@ describe("GoalTool", () => { }), ); - const result = await tool.execute("call-resume", { op: "resume" }); + const result = await tool.execute("call-resume", { op: "resume", objective: undefined, token_budget: undefined }); expect(result.details?.op).toBe("resume"); expect(result.details?.goal?.status).toBe("active"); expect(harness.getState()?.enabled).toBe(true); @@ -298,7 +311,7 @@ describe("GoalTool", () => { }), ); - const result = await tool.execute("call-drop", { op: "drop" }); + const result = await tool.execute("call-drop", { op: "drop", objective: undefined, token_budget: undefined }); expect(result.details?.op).toBe("drop"); expect(result.details?.goal?.status).toBe("dropped"); expect(harness.getState()).toBeUndefined(); diff --git a/packages/coding-agent/test/helpers/acp-schema.ts b/packages/coding-agent/test/helpers/acp-schema.ts index e4d4d05ac..28ab2ab2a 100644 --- a/packages/coding-agent/test/helpers/acp-schema.ts +++ b/packages/coding-agent/test/helpers/acp-schema.ts @@ -1,16 +1,21 @@ import { expect } from "bun:test"; -import type * as z from "zod/v4"; +import type { Type } from "arktype"; -function formatIssues(error: z.ZodError): string { - return error.issues.map(issue => `${issue.path.join(".") || ""}: ${issue.message}`).join("\n"); +function formatIssues(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); } -export function expectAcpStructure(schema: z.ZodType, value: unknown): void { - const result = schema.safeParse(value); - expect(result.success, result.success ? undefined : formatIssues(result.error)).toBe(true); +export function expectAcpStructure(schema: Type, value: unknown): void { + const result = schema(value); + const isValid = !(result instanceof Error); + expect(isValid, isValid ? undefined : formatIssues(result)).toBe(true); } -export function expectAcpStructureRejects(schema: z.ZodType, value: unknown): void { - const result = schema.safeParse(value); - expect(result.success).toBe(false); +export function expectAcpStructureRejects(schema: Type, value: unknown): void { + const result = schema(value); + const isValid = !(result instanceof Error); + expect(isValid).toBe(false); } diff --git a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts index fdd7408a2..873ba2dae 100644 --- a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts +++ b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts @@ -8,7 +8,7 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import * as z from "zod/v4"; +import { type } from "arktype"; import { ModelRegistry } from "../src/config/model-registry"; import { InteractiveMode } from "../src/modes/interactive-mode"; @@ -17,7 +17,7 @@ function makeTool(name: string): AgentTool { name, label: name, description: `Fake ${name}`, - parameters: z.object({}), + parameters: type({}), async execute() { return { content: [{ type: "text" as const, text: "ok" }] }; }, diff --git a/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts b/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts index ef3bc26cb..112d46801 100644 --- a/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts +++ b/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts @@ -20,14 +20,14 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { AutocompleteProvider } from "@oh-my-pi/pi-tui"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; function makeTool(name: string): AgentTool { return { name, label: name, description: `Fake ${name}`, - parameters: z.object({}), + parameters: type({}), async execute() { return { content: [{ type: "text" as const, text: "ok" }] }; }, diff --git a/packages/coding-agent/test/modes/context-usage.test.ts b/packages/coding-agent/test/modes/context-usage.test.ts index 04a44e9f9..e25d7c558 100644 --- a/packages/coding-agent/test/modes/context-usage.test.ts +++ b/packages/coding-agent/test/modes/context-usage.test.ts @@ -1,32 +1,32 @@ /** * Contract: tool schema token estimation reflects the wire JSON Schema. * - * Tools authored with Zod must be counted by the JSON Schema providers - * actually receive — not by stringifying the Zod instance's enumerable - * internals (`def` tree), which massively overcounts. + * Tools authored with arktype must be counted by the JSON Schema providers + * actually receive — not by stringifying the arktype instance's enumerable + * internals, which massively overcounts. */ import { describe, expect, it } from "bun:test"; -import { zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { arkToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { type ContextBreakdown, estimateToolSchemaTokens, renderContextUsage, } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; -import { z } from "zod/v4"; +import { type } from "arktype"; describe("estimateToolSchemaTokens", () => { - it("counts Zod tool schemas by their wire JSON Schema, not Zod internals", () => { - const parameters = z.object({ - query: z.string().describe("search query"), - limit: z.number().optional(), + it("counts arktype tool schemas by their wire JSON Schema, not arktype internals", () => { + const parameters = type({ + "query /** search query */": "string", + "limit?": "number", }); - const zodEstimate = estimateToolSchemaTokens([ + const arktypeEstimate = estimateToolSchemaTokens([ { name: "web_search", description: "Searches the web.", parameters } as never, ]); const wireEstimate = estimateToolSchemaTokens([ - { name: "web_search", description: "Searches the web.", parameters: zodToWireSchema(parameters) } as never, + { name: "web_search", description: "Searches the web.", parameters: arkToWireSchema(parameters) } as never, ]); - expect(zodEstimate).toBe(wireEstimate); + expect(arktypeEstimate).toBe(wireEstimate); }); }); diff --git a/packages/coding-agent/test/sdk-mcp-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-discovery.test.ts index d4a7acbb0..9e90dbedf 100644 --- a/packages/coding-agent/test/sdk-mcp-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-discovery.test.ts @@ -13,7 +13,7 @@ import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TOOL_DISCOVERY_AUTO_THRESHOLD } from "@oh-my-pi/pi-coding-agent/tool-discovery/mode"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; function createMcpCustomTool(name: string, serverName: string, mcpToolName: string): CustomTool { return { @@ -22,7 +22,7 @@ function createMcpCustomTool(name: string, serverName: string, mcpToolName: stri description: `Tool ${mcpToolName} from ${serverName}`, mcpServerName: serverName, mcpToolName, - parameters: z.object({ query: z.string() }), + parameters: type({ query: "string" }), async execute() { return { content: [{ type: "text", text: `${name} executed` }] }; }, diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index 23f595bc7..7f0e55c12 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -13,14 +13,14 @@ import { } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; const toolActivationExtension: ExtensionFactory = pi => { pi.registerTool({ name: "default_inactive_tool", label: "Default Inactive Tool", description: "Tool hidden from the initial active set unless explicitly requested.", - parameters: z.object({}), + parameters: type({}), defaultInactive: true, async execute() { return { content: [{ type: "text", text: "inactive" }] }; @@ -30,7 +30,7 @@ const toolActivationExtension: ExtensionFactory = pi => { name: "default_active_tool", label: "Default Active Tool", description: "Tool included in the initial active set.", - parameters: z.object({}), + parameters: type({}), async execute() { return { content: [{ type: "text", text: "active" }] }; }, diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index ad0716dee..94df47c88 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -10,7 +10,7 @@ import { SecretObfuscator, } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; import { compileSecretRegex } from "@oh-my-pi/pi-coding-agent/secrets/regex"; -import { z } from "zod/v4"; +import { type } from "arktype"; describe("compileSecretRegex", () => { it("adds global flag when not provided", () => { @@ -90,12 +90,12 @@ describe("SecretObfuscator regex behavior", () => { expect(obfuscator.deobfuscateObject(obfuscated).tools?.[0]?.description).toEqual(payload.tools[0]?.description); }); - it("redacts Zod tool schemas without cloning the live schema instance", () => { + it("redacts arktype tool schemas without cloning the live schema instance", () => { const secret = "SUPER_SECRET_TOKEN_12345"; const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); - const parameters = z.object({ - note: z.string().describe(`write ${secret}`), - }); + const parameters = type({ + note: "string", + }).describe(`write ${secret}`); const context: Context = { messages: [], tools: [ diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index f0afa5ccf..9bff95800 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -3,14 +3,14 @@ * renderer — a simplified TypeScript signature (derived from the wire JSON * Schema) plus each tool's examples in the model's native tool-call syntax. * - * Tools carry live Zod v4 schemas; the dump must surface a readable signature + * Tools carry live arktype schemas; the dump must surface a readable signature * (not the schema instance's internals) and must include examples, which the * previous ``-per-key JSON Schema dump dropped entirely. */ import { describe, expect, it } from "bun:test"; import type { Model, Usage } from "@oh-my-pi/pi-ai"; import { formatSessionDumpText } from "@oh-my-pi/pi-coding-agent/session/session-dump-format"; -import { z } from "zod/v4"; +import { type } from "arktype"; const ZERO_USAGE: Usage = { input: 0, @@ -24,17 +24,19 @@ const ZERO_USAGE: Usage = { const HARMONY_MODEL = { provider: "openai", id: "gpt-5", name: "GPT-5" } as Model; describe("formatSessionDumpText tool parameters", () => { - it("renders Zod schemas as a TypeScript signature, not schema internals", () => { + it("renders arktype schemas as a TypeScript signature, not schema internals", () => { + const webSearchSchema = type({ + "query /** search query */": "string", + "recency?": "'day' | 'week'", + }); + const out = formatSessionDumpText({ messages: [], tools: [ { name: "web_search", description: "Searches the web.", - parameters: z.object({ - query: z.string().describe("search query"), - recency: z.enum(["day", "week"]).optional(), - }), + parameters: webSearchSchema.json, }, ], }); @@ -44,9 +46,9 @@ describe("formatSessionDumpText tool parameters", () => { expect(out).toContain("/** search query */"); expect(out).toContain("query: string;"); expect(out).toContain('recency?: "day" | "week";'); - // Live Zod instance internals must never leak into the dump. - expect(out).not.toContain("_zod"); - expect(out).not.toContain("ZodObject"); + // Arktype JSON Schema should not leak arktype internals into the dump. + expect(out).not.toContain("_arktype"); + expect(out).not.toContain("ArkType"); // Tool params are no longer emitted as XML elements. expect(out).not.toContain(''); }); @@ -73,13 +75,15 @@ describe("formatSessionDumpText tool parameters", () => { }); it("includes tool examples in the model's native syntax", () => { + const findSchema = type({ paths: "string[]" }); + const out = formatSessionDumpText({ messages: [], tools: [ { name: "find", description: "Finds files.", - parameters: z.object({ paths: z.array(z.string()) }), + parameters: findSchema.json, examples: [{ call: { paths: ["src/**/*.ts"] } }], }, ], diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 189784f8e..137bd4fb5 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -18,7 +18,7 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import * as autoGeneratedGuard from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; +import { type } from "arktype"; function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { return { @@ -115,11 +115,11 @@ async function createSession( } function buildEditTool(): AgentTool { - const schema = z.object({ - path: z.string(), - diff: z.string(), - op: z.string().optional(), - rename: z.string().optional(), + const schema = type({ + path: "string", + diff: "string", + op: "string?", + rename: "string?", }); return { diff --git a/packages/coding-agent/test/task/role-specialization.test.ts b/packages/coding-agent/test/task/role-specialization.test.ts index ff3481812..bf55dcc65 100644 --- a/packages/coding-agent/test/task/role-specialization.test.ts +++ b/packages/coding-agent/test/task/role-specialization.test.ts @@ -10,6 +10,7 @@ import { } from "@oh-my-pi/pi-coding-agent/task/types"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { prompt } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; import subagentSystemPromptTemplate from "../../src/prompts/system/subagent-system-prompt.md" with { type: "text" }; // Contract: a per-spawn `role` gives a subagent a tailored identity. The role @@ -86,35 +87,35 @@ describe("subagent system prompt role preamble", () => { describe("task schema accepts role", () => { it("keeps role on the flat single-spawn shape", () => { - const parsed = taskSchema.safeParse({ agent: "task", assignment: "x", role: "Rust specialist" }); - expect(parsed.success).toBe(true); - if (parsed.success) { - expect(parsed.data.role).toBe("Rust specialist"); + const parsed = taskSchema({ agent: "task", assignment: "x", role: "Rust specialist" }); + expect(parsed instanceof type.errors).toBe(false); + if (!(parsed instanceof type.errors)) { + expect(parsed.role).toBe("Rust specialist"); } }); it("keeps role on batch task items", () => { const batch = getTaskSchema({ isolationEnabled: false, batchEnabled: true }); - const parsed = batch.safeParse({ + const parsed = batch({ agent: "task", context: "ctx", tasks: [{ assignment: "x", role: "DB migration specialist" }], }); - expect(parsed.success).toBe(true); - if (parsed.success && "tasks" in parsed.data) { - const tasks = parsed.data.tasks as Array<{ role?: string }>; + expect(parsed instanceof type.errors).toBe(false); + if (!(parsed instanceof type.errors) && "tasks" in parsed) { + const tasks = parsed.tasks as Array<{ role?: string }>; expect(tasks[0]?.role).toBe("DB migration specialist"); } }); it("rejects a role longer than the schema bound", () => { - const parsed = taskSchema.safeParse({ agent: "task", assignment: "x", role: "x".repeat(ROLE_INPUT_MAX + 1) }); - expect(parsed.success).toBe(false); + const parsed = taskSchema({ agent: "task", assignment: "x", role: "x".repeat(ROLE_INPUT_MAX + 1) }); + expect(parsed instanceof type.errors).toBe(true); }); it("accepts a role at the schema bound", () => { - const parsed = taskSchema.safeParse({ agent: "task", assignment: "x", role: "x".repeat(ROLE_INPUT_MAX) }); - expect(parsed.success).toBe(true); + const parsed = taskSchema({ agent: "task", assignment: "x", role: "x".repeat(ROLE_INPUT_MAX) }); + expect(parsed instanceof type.errors).toBe(false); }); }); diff --git a/packages/coding-agent/test/task/task-schema.test.ts b/packages/coding-agent/test/task/task-schema.test.ts index 0327fd98a..2737b8e70 100644 --- a/packages/coding-agent/test/task/task-schema.test.ts +++ b/packages/coding-agent/test/task/task-schema.test.ts @@ -3,6 +3,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { TaskTool, taskSchema } from "@oh-my-pi/pi-coding-agent/task"; import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { type } from "arktype"; // Contract: the single-spawn schema (`task.batch: false`; the exported // `taskSchema` instance) carries no batch fields. The batch shape (`tasks[]` + @@ -12,35 +13,35 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; describe("task schema (single-spawn)", () => { it("accepts {agent, assignment}", () => { - const parsed = taskSchema.safeParse({ agent: "explore", assignment: "Map the auth module." }); - expect(parsed.success).toBe(true); + const parsed = taskSchema({ agent: "explore", assignment: "Map the auth module." }); + expect(parsed instanceof type.errors).toBe(false); }); it("requires agent", () => { - const parsed = taskSchema.safeParse({ assignment: "Map the auth module." }); - expect(parsed.success).toBe(false); + const parsed = taskSchema({ assignment: "Map the auth module." }); + expect(parsed instanceof type.errors).toBe(true); }); it("requires assignment", () => { - const parsed = taskSchema.safeParse({ agent: "explore" }); - expect(parsed.success).toBe(false); + const parsed = taskSchema({ agent: "explore" }); + expect(parsed instanceof type.errors).toBe(true); }); it("strips tasks/context/schema from the single-spawn schema", () => { - const parsed = taskSchema.safeParse({ + const parsed = taskSchema({ agent: "explore", assignment: "Map the auth module.", context: "shared background", tasks: [{ id: "A", assignment: "..." }], schema: '{"properties":{}}', }); - expect(parsed.success).toBe(true); - if (parsed.success) { + expect(parsed instanceof type.errors).toBe(false); + if (!(parsed instanceof type.errors)) { // Unknown keys are stripped: batch/context exist only on the batch // schema and the per-call schema input was removed outright. - expect("tasks" in parsed.data).toBe(false); - expect("context" in parsed.data).toBe(false); - expect("schema" in parsed.data).toBe(false); + expect("tasks" in parsed).toBe(false); + expect("context" in parsed).toBe(false); + expect("schema" in parsed).toBe(false); } }); }); diff --git a/packages/coding-agent/test/tool-discovery/tool-index.test.ts b/packages/coding-agent/test/tool-discovery/tool-index.test.ts index 2783c245d..920cd70ef 100644 --- a/packages/coding-agent/test/tool-discovery/tool-index.test.ts +++ b/packages/coding-agent/test/tool-discovery/tool-index.test.ts @@ -11,7 +11,7 @@ import { selectDiscoverableToolNamesByServer, summarizeDiscoverableTools, } from "@oh-my-pi/pi-coding-agent/tool-discovery/tool-index"; -import { z } from "zod/v4"; +import { type } from "arktype"; // ─── Minimal AgentTool stub ─────────────────────────────────────────────────── @@ -120,7 +120,7 @@ describe("getDiscoverableTool", () => { it("extracts schema keys from Zod-schema parameters via wire conversion", () => { const tool = makeAgentTool("foo", { - parameters: z.object({ gamma: z.string(), alpha: z.number().optional(), beta: z.boolean() }), + parameters: type({ gamma: "string", alpha: "number?", beta: "boolean" }), }); const result = getDiscoverableTool(tool); expect(result!.schemaKeys).toEqual(["alpha", "beta", "gamma"]); diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index 88106e909..5b0d16089 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -13,7 +13,6 @@ import { } from "@oh-my-pi/pi-coding-agent/tools/gh"; import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; import { getAgentDir, hashPath, setAgentDir } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; // Isolate every `git` invocation in this file from the developer's host // configuration. The fixture spawns dozens of git subprocesses against tiny @@ -958,7 +957,7 @@ describe("github tool", () => { it("exposes a flat op-based schema without legacy run_watch parameters", () => { const tool = new GithubTool(createSession()); - const wire = z.toJSONSchema(tool.parameters, { target: "draft-2020-12" }) as Record; + const wire = tool.parameters.json as Record; const properties = wire.properties as Record; expect(properties.op).toBeDefined(); expect(properties.interval).toBeUndefined(); diff --git a/packages/coding-agent/test/tools/inspect-image.test.ts b/packages/coding-agent/test/tools/inspect-image.test.ts index 7ffedb0fa..c01fa906f 100644 --- a/packages/coding-agent/test/tools/inspect-image.test.ts +++ b/packages/coding-agent/test/tools/inspect-image.test.ts @@ -11,6 +11,7 @@ import { InspectImageTool } from "@oh-my-pi/pi-coding-agent/tools/inspect-image" import { inspectImageToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/inspect-image-renderer"; import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers"; import { sanitizeText } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; @@ -203,10 +204,10 @@ describe("InspectImageTool", () => { it("schema rejects unknown parameters", () => { const tool = new InspectImageTool(createSession(testDir, visionModel)); expect(tool.strict).toBe(false); - expect(tool.parameters.safeParse({ path: "img.png", question: "What is visible?" }).success).toBe(true); - expect(tool.parameters.safeParse({ path: "img.png", question: "What is visible?", extra: "nope" }).success).toBe( - false, - ); + expect(tool.parameters({ path: "img.png", question: "What is visible?" }) instanceof type.errors).toBe(false); + expect( + tool.parameters({ path: "img.png", question: "What is visible?", extra: "nope" }) instanceof type.errors, + ).toBe(true); }); it("fails when images.blockImages is enabled", async () => { diff --git a/packages/coding-agent/test/tools/resolve.test.ts b/packages/coding-agent/test/tools/resolve.test.ts index 90e6fec1a..5df4bc042 100644 --- a/packages/coding-agent/test/tools/resolve.test.ts +++ b/packages/coding-agent/test/tools/resolve.test.ts @@ -4,7 +4,6 @@ import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { ResolveTool, resolveToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/resolve"; import { sanitizeText } from "@oh-my-pi/pi-utils"; -import { z } from "zod/v4"; function createSession(handler?: (input: unknown) => Promise): ToolSession { return { @@ -21,11 +20,19 @@ function getText(result: { content: Array<{ type: string; text?: string }> }): s return result.content.find(part => part.type === "text")?.text ?? ""; } +function getRequiredFields(schema: { json?: { required?: Array<{ key: string }> } }): string[] { + const required: string[] = []; + if (schema.json?.required && Array.isArray(schema.json.required)) { + required.push(...schema.json.required.map(item => item.key)); + } + return required.sort(); +} + describe("ResolveTool", () => { it("requires action and reason in schema", () => { const tool = new ResolveTool(createSession()); - const wire = z.toJSONSchema(tool.parameters, { target: "draft-2020-12" }) as { required?: string[] }; - expect(wire.required).toEqual(["action", "reason"]); + const required = getRequiredFields(tool.parameters as { json?: { required?: Array<{ key: string }> } }); + expect(required).toEqual(["action", "reason"]); }); it("errors when there is no pending action", async () => {