diff --git a/bun.lock b/bun.lock index a73123030..600131845 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,6 @@ "devDependencies": { "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", - "@sinclair/typebox": "catalog:", "@types/bun": "catalog:", }, }, @@ -43,10 +42,7 @@ "@google/genai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", - "@sinclair/typebox": "catalog:", "@smithy/node-http-handler": "catalog:", - "ajv": "catalog:", - "ajv-formats": "catalog:", "openai": "catalog:", "partial-json": "catalog:", "proxy-agent": "catalog:", @@ -73,10 +69,8 @@ "@oh-my-pi/pi-tui": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@puppeteer/browsers": "catalog:", - "@sinclair/typebox": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", - "ajv": "catalog:", "chalk": "catalog:", "diff": "catalog:", "fflate": "catalog:", @@ -197,9 +191,6 @@ }, }, }, - "overrides": { - "ajv": "8.20.0", - }, "catalog": { "@agentclientprotocol/sdk": "0.21.0", "@anthropic-ai/sdk": "^0.94.0", @@ -226,7 +217,6 @@ "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@puppeteer/browsers": "^2.13.0", - "@sinclair/typebox": "^0.34.49", "@smithy/node-http-handler": "^4.6.1", "@tailwindcss/node": "^4.2.4", "@types/babel__generator": "^7.27.0", @@ -237,8 +227,6 @@ "@types/turndown": "5.0.6", "@typescript/native-preview": "7.0.0-dev.20260505.1", "@xterm/headless": "^6.0.0", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", "beautiful-mermaid": "^1.1.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", @@ -631,8 +619,6 @@ "@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="], - "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], - "@smithy/config-resolver": ["@smithy/config-resolver@4.5.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-m5PNfr7xKdIegNG8DlLz+Gf/DlAhHWFGmFbe0DZo9pnvBwuZ3P/9OMtQU0UyWMYy8zjl+HDFVS7rdD9p2xEFjQ=="], "@smithy/core": ["@smithy/core@3.24.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-rZ5YfycIXX6puoGjthnDiMpUgtKNOq3c7CndQYkCNYQTv26AiCrZQOJPy7ANSfZ6Okk3UvCRnmO1OYWlLnYZgg=="], @@ -765,10 +751,6 @@ "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -927,16 +909,12 @@ "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], @@ -1015,8 +993,6 @@ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], @@ -1175,8 +1151,6 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], diff --git a/docs/custom-tools.md b/docs/custom-tools.md index 2c5d1dde7..a953920b9 100644 --- a/docs/custom-tools.md +++ b/docs/custom-tools.md @@ -6,7 +6,7 @@ A custom tool is a TypeScript/JavaScript module that exports a factory. The fact ## What this is (and is not) -- **Custom tool**: callable by the model during a turn (`execute` + TypeBox schema). +- **Custom tool**: callable by the model during a turn (`execute` + Zod parameter schema; legacy TypeBox is still accepted and lifted to Zod at registration). - **Extension**: lifecycle/event framework that can register tools and intercept/modify events. - **Hook**: external pre/post command scripts. - **Skill**: static guidance/context package, not executable tool code. @@ -67,50 +67,46 @@ A custom tool module must export a function (default export preferred): import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent"; const factory: CustomToolFactory = (pi) => ({ - name: "repo_stats", - label: "Repo Stats", - description: "Counts tracked TypeScript files", - parameters: pi.typebox.Type.Object({ - glob: pi.typebox.Type.Optional( - pi.typebox.Type.String({ default: "**/*.ts" }), - ), - }), + name: "repo_stats", + label: "Repo Stats", + description: "Counts tracked TypeScript files", + parameters: pi.zod.object({ + glob: pi.zod.string().optional().default("**/*.ts"), + }), - async execute(toolCallId, params, onUpdate, ctx, signal) { - onUpdate?.({ - content: [{ type: "text", text: "Scanning files..." }], - details: { phase: "scan" }, - }); + async execute(toolCallId, params, onUpdate, ctx, signal) { + onUpdate?.({ + content: [{ type: "text", text: "Scanning files..." }], + details: { phase: "scan" }, + }); - const result = await pi.exec( - "git", - ["ls-files", params.glob ?? "**/*.ts"], - { signal, cwd: pi.cwd }, - ); - if (result.killed) { - throw new Error("Scan was cancelled"); - } - if (result.code !== 0) { - throw new Error(result.stderr || "git ls-files failed"); - } + const result = await pi.exec("git", ["ls-files", params.glob ?? "**/*.ts"], { signal, cwd: pi.cwd }); + if (result.killed) { + throw new Error("Scan was cancelled"); + } + if (result.code !== 0) { + throw new Error(result.stderr || "git ls-files failed"); + } - const files = result.stdout.split("\n").filter(Boolean); - return { - content: [{ type: "text", text: `Found ${files.length} files` }], - details: { count: files.length, sample: files.slice(0, 10) }, - }; - }, + const files = result.stdout.split("\n").filter(Boolean); + return { + content: [{ type: "text", text: `Found ${files.length} files` }], + details: { count: files.length, sample: files.slice(0, 10) }, + }; + }, - onSession(event) { - if (event.reason === "shutdown") { - // cleanup resources if needed - } - }, + onSession(event) { + if (event.reason === "shutdown") { + // cleanup resources if needed + } + }, }); export default factory; ``` +Legacy TypeBox-authored factories can still call `pi.typebox` — it's now a small Zod-backed shim (`Type.Object`, `Type.String`, etc.) baked into the host, not the real `@sinclair/typebox` package. Schemas flow through the same Zod pipeline as `pi.zod` and need no separate normalization. + Factory return type: - `CustomTool` @@ -126,7 +122,8 @@ From `types.ts` and `loader.ts`: - `ui`: UI context (can be no-op in headless modes) - `hasUI`: `false` in non-interactive flows - `logger`: shared file logger -- `typebox`: injected `@sinclair/typebox` +- `zod`: injected `zod` module (**preferred** for new tool schemas; use `pi.zod.object`, `pi.zod.string`, …) +- `typebox`: injected zod-backed `Type.*` shim (legacy extension compatibility) - `pi`: injected `@oh-my-pi/pi-coding-agent` exports - `pushPendingAction(action)`: register a preview action for hidden `resolve` tool (`docs/resolve-tool-runtime.md`) @@ -140,7 +137,7 @@ Loader starts with a no-op UI context and requires host code to call `setUIConte execute(toolCallId, params, onUpdate, ctx, signal); ``` -- `params` is statically typed from your TypeBox schema via `Static`. +- `params` is statically typed from your Zod schema via `z.infer` (`Static` in API types). Legacy TypeBox schemas are lifted to Zod internally. - Runtime argument validation happens before execution in the agent loop. - `onUpdate` emits partial results for UI streaming. - `ctx` includes session/model state and an `abort()` helper. diff --git a/docs/extensions.md b/docs/extensions.md index 3b3dbf861..1953a98d5 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -66,9 +66,10 @@ Important constraint from `loader.ts`: ```ts import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; export default function (pi: ExtensionAPI) { + const { z } = pi.zod; + pi.setLabel("Safety + Utilities"); pi.on("session_start", async (_event, ctx) => { @@ -85,7 +86,7 @@ export default function (pi: ExtensionAPI) { name: "hello_extension", label: "Hello Extension", description: "Return a greeting", - parameters: Type.Object({ name: Type.String() }), + parameters: z.object({ name: z.string() }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { return { content: [{ type: "text", text: `Hello, ${params.name}` }], @@ -124,7 +125,8 @@ In interactive mode, `input` handlers run before the built-in first-message auto Also exposed: - `pi.logger` -- `pi.typebox` +- `pi.zod` (injected `zod` module — **preferred** for new tool schemas) +- `pi.typebox` (zod-backed `Type.*` shim — retained for legacy extension compat) - `pi.pi` (package exports) ### Message delivery semantics @@ -239,11 +241,13 @@ execute( Template: ```ts +const { z } = pi.zod; + pi.registerTool({ name: "my_tool", label: "My Tool", description: "...", - parameters: Type.Object({}), + parameters: z.object({}), async execute(_id, _params, signal, onUpdate, ctx) { if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }] }; diff --git a/docs/resolve-tool-runtime.md b/docs/resolve-tool-runtime.md index c8256067d..63d65233c 100644 --- a/docs/resolve-tool-runtime.md +++ b/docs/resolve-tool-runtime.md @@ -65,8 +65,8 @@ const factory: CustomToolFactory = (pi) => ({ name: "batch_rename_preview", label: "Batch Rename Preview", description: "Previews renames and defers commit to resolve", - parameters: pi.typebox.Type.Object({ - files: pi.typebox.Type.Array(pi.typebox.Type.String()), + parameters: pi.zod.object({ + files: pi.zod.array(pi.zod.string()), }), async execute(_toolCallId, params) { diff --git a/docs/skills/authoring-extensions.md b/docs/skills/authoring-extensions.md index e06c4d9c3..cf877c5d5 100644 --- a/docs/skills/authoring-extensions.md +++ b/docs/skills/authoring-extensions.md @@ -29,7 +29,7 @@ The following extension registers a slash command, a tool, and a session-start h import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function myExtension(pi: ExtensionAPI) { - const { Type } = pi.typebox; + const z = pi.zod; // Runs once when the session loads pi.on("session_start", async (_event, ctx) => { @@ -59,8 +59,8 @@ export default function myExtension(pi: ExtensionAPI) { name: "word_count", label: "Word Count", description: "Count the words in a string", - parameters: Type.Object({ - text: Type.String({ description: "Text to count" }), + parameters: z.object({ + text: z.string().describe("Text to count"), }), async execute(_id, params, _signal, _onUpdate, _ctx) { const count = params.text.split(/\s+/).filter(Boolean).length; @@ -154,18 +154,18 @@ pi.registerCommand("my-cmd", { ## Registering tools -Tools are called by the LLM. Parameters use [TypeBox](https://github.com/sinclairzx81/typebox) schemas, available at `pi.typebox`: +Tools are called by the LLM. Parameters use [Zod](https://zod.dev) schemas, available at `pi.zod`: ```ts -const { Type } = pi.typebox; +const z = pi.zod; pi.registerTool({ name: "search_notes", // snake_case, unique label: "Search Notes", // human-readable label for TUI description: "Full-text search through project notes", - parameters: Type.Object({ - query: Type.String({ description: "Search query" }), - limit: Type.Optional(Type.Number({ description: "Max results", default: 10 })), + parameters: z.object({ + query: z.string().describe("Search query"), + limit: z.number().default(10).describe("Max results").optional(), }), async execute(toolCallId, params, signal, onUpdate, ctx) { if (signal?.aborted) { diff --git a/package.json b/package.json index 1ff3473c3..360304819 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@puppeteer/browsers": "^2.13.0", - "@sinclair/typebox": "^0.34.49", "@smithy/node-http-handler": "^4.6.1", "@tailwindcss/node": "^4.2.4", "@types/babel__generator": "^7.27.0", @@ -44,8 +43,6 @@ "@types/turndown": "5.0.6", "@typescript/native-preview": "7.0.0-dev.20260505.1", "@xterm/headless": "^6.0.0", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", "beautiful-mermaid": "^1.1.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", @@ -78,9 +75,7 @@ "zod": "4.4.3" } }, - "overrides": { - "ajv": "8.20.0" - }, + "overrides": {}, "scripts": { "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link", "dev": "bun --cwd=packages/coding-agent src/cli.ts", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index f2a9d13f3..b1677a93f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Removed legacy telemetry constants from the public enum surface (including `AGGREGATE_ATTR`, `GenAIAttr.System`, and old `gen_ai.*` extension keys such as `gen_ai.request.service_tier`/cost/tool status/handoff fields) and replaced them with `OpenAIAttr`, `PiGenAIAttr`, and `PiGenAIAggregateAttr` ### Added +- Added support for defining `AgentTool` schemas with Zod, with legacy TypeBox schemas still supported when generating tool schemas for model calls - Added `OpenAIAttr`, `PiGenAIAttr`, and `PiGenAIAggregateAttr` exports so consumers can reference the new `openai.*` and `pi.gen_ai.*` telemetry attribute keys directly - Added `onChatUsage` to `AgentTelemetryConfig`, an always-fired hook receiving a `ChatUsageEvent` for every chat step that produced usage. The event carries the chat `span`, `agent`, `conversationId`, `stepNumber`, `model`, `provider`, `serviceTier`, `usage`, optional `cost`, and resolved dynamic `attributes` — independent of whether a `costEstimator` is configured. - Added `agentLoopDetailed(...)` and `agentLoopContinueDetailed(...)` helpers that return the same event stream plus a `detailed()` result with run `telemetry` and `coverage` @@ -32,6 +34,7 @@ ### Fixed +- Fixed intent-field injection for tool schemas defined with Zod by converting them to wire schema before mutation - Fixed token accounting in `ChatUsageEvent` and usage summaries so `inputTokens` and `totalTokens` now include cached read/write input tokens - Fixed `execute_tool` span attributes so `pi.gen_ai.tool.status` and `error.type` now reflect run-level tool outcomes (`ok`, `error`, `skipped`, `blocked`, `timeout`, `aborted`) instead of mapping all non-ok cases the same way - Fixed `onRunEnd` callbacks to be safe and idempotent by invoking them once per run and swallowing thrown callback errors so they cannot fail or duplicate successful runs diff --git a/packages/agent/README.md b/packages/agent/README.md index f10435f89..ce3a8e160 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -279,17 +279,17 @@ const agent = new Agent({ ## Tools -Define tools using `AgentTool`: +Define tools using `AgentTool` with a Zod parameter schema (via `z` from `@oh-my-pi/pi-ai`). Legacy TypeBox-authored schemas are still accepted at runtime and are lifted to Zod internally. ```typescript -import { Type } from "@sinclair/typebox"; +import { z } from "@oh-my-pi/pi-ai"; const readFileTool: AgentTool = { name: "read_file", label: "Read File", // For UI display description: "Read a file's contents", - parameters: Type.Object({ - path: Type.String({ description: "File path" }), + parameters: z.object({ + path: z.string().describe("File path"), }), execute: async (toolCallId, params, signal, onUpdate, context) => { const content = await fs.readFile(params.path, "utf-8"); @@ -461,7 +461,7 @@ const runCoverage = aggregateAgentRunCoverage(coverages); ### Tool status reporting -`execute_tool` spans carry `gen_ai.tool.status` ∈ +`execute_tool` spans carry `pi.gen_ai.tool.status` ∈ `"ok" | "error" | "skipped" | "blocked" | "timeout" | "aborted"`. `beforeToolCall` blocks throw a distinguishable `ToolCallBlockedError` internally; the catch path reports `status: "blocked"` instead of conflating diff --git a/packages/agent/package.json b/packages/agent/package.json index 66678feff..46ce56fb2 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -43,7 +43,6 @@ "devDependencies": { "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", - "@sinclair/typebox": "catalog:", "@types/bun": "catalog:" }, "engines": { diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 129c1e37e..5a805a4f8 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -7,9 +7,12 @@ import { type AssistantMessageEvent, type Context, EventStream, + isZodSchema, streamSimple, type ToolResultMessage, + type TSchema, validateToolArguments, + zodToWireSchema, } from "@oh-my-pi/pi-ai"; import { sanitizeText } from "@oh-my-pi/pi-natives"; import { @@ -27,6 +30,7 @@ import { finishExecuteToolSpan, finishInvokeAgentSpan, fireOnRunEnd, + PiGenAIAttr, recordSkippedTool, resolveTelemetry, runInActiveSpan, @@ -363,10 +367,15 @@ function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Co injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1"; return tools?.map(t => { const intentMode = resolveIntentMode(t.intent); - const parameters = - injectIntent && intentMode !== "omit" - ? (injectIntentIntoSchema(t.parameters, intentMode) as typeof t.parameters) - : t.parameters; + let parameters: TSchema = t.parameters; + if (injectIntent && intentMode !== "omit") { + if (isZodSchema(parameters)) { + const wired = zodToWireSchema(parameters); + parameters = injectIntentIntoSchema(wired, intentMode) as TSchema; + } else { + parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema; + } + } const description = t.description ?? ""; return { ...t, parameters, description }; }); diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 2f27bb624..26e11b468 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -7,13 +7,14 @@ import type { Message, Model, SimpleStreamOptions, + Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, + TSchema, } from "@oh-my-pi/pi-ai"; -import type { Static, TSchema } from "@sinclair/typebox"; import type { HarmonyAuditEvent } from "./harmony-leak"; import type { AgentRunCoverage, AgentRunSummary } from "./run-collector"; import type { AgentTelemetryConfig } from "./telemetry"; diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index a57d8878a..44204ab86 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, Message, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { createAssistantMessage, createUserMessage } from "./helpers"; // Simple identity converter for tests - just passes through standard messages @@ -172,7 +172,7 @@ describe("agentLoop with AgentMessage", () => { }); it("provides tool call batch context", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const contexts: ToolCallContext[] = []; const tool: AgentTool = { name: "echo", @@ -227,7 +227,7 @@ describe("agentLoop with AgentMessage", () => { }); it("should handle tool calls and results", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -274,7 +274,7 @@ describe("agentLoop with AgentMessage", () => { }); it("injects and strips intent when intent tracing is enabled", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const executedParams: Record[] = []; const tool: AgentTool = { name: "echo", @@ -323,7 +323,9 @@ describe("agentLoop with AgentMessage", () => { ) as AssistantMessage | undefined; const tracedToolCall = assistantWithToolCall?.content.find(content => content.type === "toolCall"); - const firstRequestToolSchema = mock.calls[0]?.context.tools?.[0]?.parameters; + const firstRequestToolSchema = mock.calls[0]?.context.tools?.[0]?.parameters as + | { properties?: Record; required?: string[] } + | undefined; expect(firstRequestToolSchema?.properties).toMatchObject({ value: { type: "string" }, [INTENT_FIELD]: { type: "string" }, @@ -337,7 +339,7 @@ describe("agentLoop with AgentMessage", () => { }); it("runs shared tools in parallel and emits completion-ordered results", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const startTimes: Record = {}; const finishTimes: Record = {}; const { promise: slowContinue, resolve: slowResolve } = Promise.withResolvers(); @@ -468,7 +470,7 @@ describe("agentLoop with AgentMessage", () => { }); it("should skip remaining tool calls when steering is queued", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -557,7 +559,7 @@ describe("agentLoop with AgentMessage", () => { }); it("refreshes tools and system prompt between same-turn model calls", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); let activeSystemPrompt = "prompt-one"; let activeTools: Array> = []; const betaTool: AgentTool = { @@ -722,7 +724,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("blocks tool execution when beforeToolCall returns block", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const executed: string[] = []; const tool: AgentTool = { name: "echo", @@ -768,7 +770,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("passes beforeToolCall args mutations into tool.execute without revalidation", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const executed: Array = []; const tool: AgentTool = { name: "echo", @@ -810,7 +812,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("afterToolCall overrides content and isError on the emitted tool result", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const tool: AgentTool = { name: "echo", label: "Echo", @@ -874,7 +876,7 @@ describe("agentLoopContinue with AgentMessage", () => { }); it("surfaces afterToolCall errors as a tool error result", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); const tool: AgentTool = { name: "echo", label: "Echo", diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index c720f6fa2..4eaad1c82 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { SimpleStreamOptions } from "@oh-my-pi/pi-ai"; +import { z } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; -import { Type } from "@sinclair/typebox"; import { createAssistantMessage } from "./helpers"; describe("Agent", () => { @@ -81,7 +81,7 @@ describe("Agent", () => { }); it("prompt() refreshes tools and system prompt between same-turn model calls", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); type Details = { value: string }; const betaTool: AgentTool = { @@ -141,7 +141,7 @@ describe("Agent", () => { }); it("prompt() drops stale forced toolChoice after same-turn tool refresh", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); type Details = { value: string }; const betaTool: AgentTool = { @@ -199,7 +199,7 @@ describe("Agent", () => { }); it("re-reads thinking level for each model call within a run", async () => { - const toolSchema = Type.Object({ value: Type.String() }); + const toolSchema = z.object({ value: z.string() }); type Details = { value: string }; const alphaTool: AgentTool = { name: "alpha", diff --git a/packages/agent/test/utils/calculate.ts b/packages/agent/test/utils/calculate.ts index 2356fec0f..391408749 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; export interface CalculateResult extends AgentToolResult { content: Array<{ type: "text"; text: string }>; @@ -15,11 +15,11 @@ export function calculate(expression: string): CalculateResult { } } -const calculateSchema = Type.Object({ - expression: Type.String({ description: "The mathematical expression to evaluate" }), +const calculateSchema = z.object({ + expression: z.string().describe("The mathematical expression to evaluate"), }); -type CalculateParams = Static; +type CalculateParams = z.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 5b2fd995a..271c12422 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {} @@ -27,13 +27,11 @@ export async function getCurrentTime(timezone?: string): Promise; +type GetCurrentTimeParams = z.infer; export const getCurrentTimeTool: AgentTool = { label: "Current Time", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index de43e48c9..55307cbd4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,9 +1,14 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed TypeBox root exports (`Type`, `Static`, and `TSchema`) from the package entrypoint, so callers importing those symbols from `@oh-my-pi/pi-ai` must migrate to `zod` or `@oh-my-pi/pi-ai/types` ### Added +- Added support for defining tool schemas with Zod (`z.object`, `z.string`, etc.) by allowing `Tool.parameters` to be either Zod schemas or legacy JSON Schema objects and converting them to provider wire format automatically +- Added package-level schema helpers in the `zod/v4` style by exporting `z` and `ZodType` from the root entrypoint - Added a `mock` API provider via `createMockModel` to build `Model<"mock">` instances for fully in-memory, deterministic assistant streams in tests - Added `streamMock` and `registerMockApi` so mock responses can be consumed through `stream()` and the global custom API registry without an external model backend - Added async/sync response scripting with optional context-based handlers, and new `push()`/`reset()` controls to drive multi-turn mock interactions and inspect per-call invocation state @@ -14,10 +19,13 @@ ### Changed +- Changed `Static` typing behavior so it now infers argument types from Zod schemas and defaults to `unknown` for non-Zod JSON Schema parameter definitions - Restored the default steady-state stream idle timeout to 120s (regressed in 15.0.0). 30s was too aggressive for reasoning models, slow proxies, and tool-call planning gaps, surfacing as repeated `Provider stream stalled while waiting for the next event` errors. Existing `PI_STREAM_IDLE_TIMEOUT_MS` / `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` overrides are unchanged. ### Fixed +- Fixed OpenAI Completions streaming to avoid treating non-output chunks (including role-only preambles) as progress events so idle-timeout watchdog behavior no longer hangs on no-op streamed chunks +- Fixed Cloud Code Assist schema compatibility checks by replacing strict AJV meta-schema validation with structural JSON Schema validation to avoid rejecting structurally valid tool schemas - Fixed lazy built-in provider streams (`anthropic-messages`, `bedrock-converse-stream`, `cursor-agent`, `google-*`, `ollama-chat`, `openai-*`) prematurely aborting slow first-token responses with `Provider stream stalled while waiting for the next event`. The lazy-stream watchdog wrapper was treating the synthetic `start` event (yielded immediately by every provider before the model emits any tokens) as the first real item, which caused the watchdog to drop from `firstItemTimeoutMs` (100s) to `idleTimeoutMs` (30s) before the upstream model had produced anything. The shared `iterateWithIdleTimeout` now keeps `awaitingFirstItem` true until a real progress item arrives, and the lazy-stream wrapper marks `start` as a non-progress keepalive ([#1073](https://github.com/can1357/oh-my-pi/pull/1073) regression). - Heal leaked Kimi K2 chat-template tool-call tokens (`<|tool_calls_section_begin|>` … `<|tool_call_argument_begin|>` … `<|tool_calls_section_end|>`) that some hosts (native `kimi-code` API, OpenRouter, Fireworks, etc.) emit into `delta.content` instead of structured `tool_calls`. The OpenAI-completions stream consumer now strips the markers from visible text, reconstructs the embedded calls as proper `toolCall` content blocks (stream-aware, token-boundary-safe), and promotes `finish_reason: stop` to `toolUse` when calls were healed. diff --git a/packages/ai/README.md b/packages/ai/README.md index 1f3836918..333d993ec 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -89,18 +89,21 @@ npm install @oh-my-pi/pi-ai ## Quick Start ```typescript -import { Type, getModel, stream, complete, Context, Tool, StringEnum } from "@oh-my-pi/pi-ai"; +import { z, getModel, stream, complete, Context, Tool, StringEnum } from "@oh-my-pi/pi-ai"; // Fully typed with auto-complete support for both providers and models const model = getModel("openai", "gpt-4o-mini"); -// Define tools with TypeBox schemas for type safety and validation +// Define tools with Zod schemas for type safety and validation const tools: Tool[] = [ { name: "get_time", description: "Get the current time", - parameters: Type.Object({ - timezone: Type.Optional(Type.String({ description: "Optional timezone (e.g., America/New_York)" })), + parameters: z.object({ + timezone: z + .string() + .optional() + .describe("Optional timezone (e.g., America/New_York)"), }), }, ]; @@ -213,34 +216,34 @@ for (const block of response.content) { ## Tools -Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using AJV. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems. +Tools enable LLMs to interact with external systems. This library uses **Zod** schemas for type-safe tool definitions with automatic validation. Schemas are converted to JSON Schema for providers as needed. ### Defining Tools ```typescript -import { Type, Tool, StringEnum } from "@oh-my-pi/pi-ai"; +import { z, Tool, StringEnum } from "@oh-my-pi/pi-ai"; -// Define tool parameters with TypeBox +// Define tool parameters with Zod const weatherTool: Tool = { name: "get_weather", description: "Get current weather for a location", - parameters: Type.Object({ - location: Type.String({ description: "City name or coordinates" }), + parameters: z.object({ + location: z.string().describe("City name or coordinates"), units: StringEnum(["celsius", "fahrenheit"], { default: "celsius" }), }), }; -// Note: For Google API compatibility, use StringEnum helper instead of Type.Enum -// Type.Enum generates anyOf/const patterns that Google doesn't support +// Note: For Google API compatibility, use the StringEnum helper instead of z.enum alone +// when you need wire-compatible { type: "string", enum: [...] } shapes. const bookMeetingTool: Tool = { name: "book_meeting", description: "Schedule a meeting", - parameters: Type.Object({ - title: Type.String({ minLength: 1 }), - startTime: Type.String({ format: "date-time" }), - endTime: Type.String({ format: "date-time" }), - attendees: Type.Array(Type.String({ format: "email" }), { minItems: 1 }), + parameters: z.object({ + title: z.string().min(1), + startTime: z.string().describe("ISO 8601 date-time"), + endTime: z.string().describe("ISO 8601 date-time"), + attendees: z.array(z.email()).min(1), }), }; ``` @@ -340,7 +343,7 @@ for await (const event of s) { ### Validating Tool Arguments -When using `agentLoop`, tool arguments are automatically validated against your TypeBox schemas before execution. If validation fails, the error is returned to the model as a tool result, allowing it to retry. +When using `agentLoop`, tool arguments are automatically validated against your Zod parameter schemas before execution. If validation fails, the error is returned to the model as a tool result, allowing it to retry. When implementing your own tool execution loop with `stream()` or `complete()`, use `validateToolCall` to validate arguments before passing them to your tools: diff --git a/packages/ai/package.json b/packages/ai/package.json index ef80f3e34..659d97010 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -48,10 +48,7 @@ "@google/genai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", - "@sinclair/typebox": "catalog:", "@smithy/node-http-handler": "catalog:", - "ajv": "catalog:", - "ajv-formats": "catalog:", "openai": "catalog:", "partial-json": "catalog:", "proxy-agent": "catalog:", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 8a60e2668..95e18631c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,5 +1,4 @@ -export type { Static, TSchema } from "@sinclair/typebox"; -export { Type } from "@sinclair/typebox"; +export { type ZodType, z } from "zod/v4"; export * from "./api-registry"; export * from "./auth-storage"; export * from "./model-cache"; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 195fced3f..880e623d0 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -46,6 +46,7 @@ import { normalizeToolCallId, resolveCacheRetention } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; import { parseStreamingJson } from "../utils/json-parse"; +import { toolWireSchema } from "../utils/schema/wire"; import { transformMessages } from "./transform-messages"; export interface BedrockOptions extends StreamOptions { @@ -668,7 +669,12 @@ function convertToolConfig( toolSpec: { name: tool.name, description: tool.description || "", - inputSchema: { json: tool.parameters }, + // Wire schema is structurally a JSON Schema document; the Bedrock SDK + // types it as the recursive `DocumentType` from `@smithy/types`, which + // `Record` does not directly satisfy at the type + // level. Cast through `unknown` so the actual JSON value passes the + // type checker without changing runtime behavior. + inputSchema: { json: toolWireSchema(tool) as unknown as Record }, }, })); diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 92aa78fbb..69f1e1d28 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -58,7 +58,7 @@ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse"; import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; import { isCopilotTransientModelError } from "../utils/retry"; -import { COMBINATOR_KEYS, NO_STRICT } from "../utils/schema"; +import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema"; import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug"; import { buildCopilotDynamicHeaders, @@ -2072,7 +2072,7 @@ export function convertAnthropicMessages( return params; } -const ANTHROPIC_UNSUPPORTED_TOOL_SCHEMA_FIELDS = new Set(["maxItems", "patternProperties"]); +const ANTHROPIC_UNSUPPORTED_TOOL_SCHEMA_FIELDS = new Set(["maxItems", "patternProperties", "propertyNames"]); const ANTHROPIC_STRICT_TOOL_ALLOWLIST = new Set(["bash", "python", "edit", "find"]); const MAX_ANTHROPIC_STRICT_TOOLS = 20; const MAX_ANTHROPIC_STRICT_OPTIONAL_PARAMETERS = 24; @@ -2314,7 +2314,7 @@ function normalizeAnthropicStrictSchema( } function buildAnthropicBaseToolInputSchema(tool: Tool): Record { - const jsonSchema = tool.parameters as Record; + const jsonSchema = toolWireSchema(tool); return normalizeAnthropicToolSchema({ ...jsonSchema, type: "object", diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index bec81251d..443e69fed 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -26,6 +26,7 @@ import { getStreamFirstEventTimeoutMs, iterateWithIdleTimeout, } from "../utils/idle-iterator"; +import { toolWireSchema } from "../utils/schema/wire"; import { wrapFetchForSseDebug } from "../utils/sse-debug"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses"; @@ -330,7 +331,7 @@ function convertTools(tools: Tool[]): OpenAITool[] { type: "function", name: tool.name, description: tool.description || "", - parameters: tool.parameters as Record, + parameters: toolWireSchema(tool), strict: false, })); } diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index b19b12810..563ef5aa0 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -30,6 +30,7 @@ import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; +import { toolWireSchema } from "../utils/schema/wire"; import type { McpToolDefinition } from "./cursor/gen/agent_pb"; import { AgentClientMessageSchema, @@ -2067,7 +2068,7 @@ function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] } return advertisedTools.map(tool => { - const jsonSchema = tool.parameters as Record | undefined; + const jsonSchema = toolWireSchema(tool); const schemaValue: JsonValue = jsonSchema && typeof jsonSchema === "object" ? (jsonSchema as JsonValue) diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 98e1ba70a..14ca2e50e 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -30,7 +30,7 @@ import type { import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; -import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema"; +import { prepareSchemaForCCA, sanitizeSchemaForGoogle, toolWireSchema } from "../utils/schema"; import { transformMessages } from "./transform-messages"; import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard"; @@ -340,8 +340,8 @@ export function convertTools( name: tool.name, description: tool.description || "", ...(useParameters - ? { parameters: prepareSchemaForCCA(tool.parameters) } - : { parametersJsonSchema: tool.parameters }), + ? { parameters: prepareSchemaForCCA(toolWireSchema(tool)) } + : { parametersJsonSchema: toolWireSchema(tool) }), })), }, ]; diff --git a/packages/ai/src/providers/ollama.ts b/packages/ai/src/providers/ollama.ts index ddba1dd5f..4b5ecdb65 100644 --- a/packages/ai/src/providers/ollama.ts +++ b/packages/ai/src/providers/ollama.ts @@ -1,5 +1,4 @@ import { fetchWithRetry } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; import { getEnvApiKey } from "../stream"; import type { Api, @@ -19,6 +18,7 @@ import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { parseStreamingJson } from "../utils/json-parse"; +import { toolWireSchema } from "../utils/schema/wire"; import { transformMessages } from "./transform-messages"; export interface OllamaChatOptions extends StreamOptions { @@ -31,7 +31,7 @@ type OllamaFunctionTool = { function: { name: string; description: string; - parameters: TSchema; + parameters: Record; }; }; @@ -223,7 +223,7 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin function: { name: tool.name, description: tool.description, - parameters: tool.parameters, + parameters: toolWireSchema(tool), }, })); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index b9868d982..c65bce4ce 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -40,7 +40,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator"; import { parseStreamingJson } from "../utils/json-parse"; -import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; +import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema"; import { compactGrammarDefinition } from "./grammar"; import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants"; import { @@ -2482,7 +2482,7 @@ export function convertOpenAICodexResponsesTools( }; } const strict = !!(!NO_STRICT && tool.strict); - const baseParameters = tool.parameters as unknown as Record; + const baseParameters = toolWireSchema(tool); const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict); return { type: "function", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 9c6b1f025..d0eb24e73 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -55,7 +55,7 @@ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { getKimiCommonHeaders } from "../utils/oauth/kimi"; import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; -import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema"; +import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema"; import { wrapFetchForSseDebug } from "../utils/sse-debug"; import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing"; import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice"; @@ -181,7 +181,7 @@ function hasToolHistory(messages: Message[]): boolean { * Role-only `delta: { role: "assistant" }` preambles do NOT count; we want the * (longer) first-event timeout to keep governing until real output appears. */ -function isOpenAICompletionsProgressChunk(chunk: unknown): boolean { +export function isOpenAICompletionsProgressChunk(chunk: unknown): boolean { if (!chunk || typeof chunk !== "object") return false; const record = chunk as { usage?: unknown; @@ -1670,7 +1670,7 @@ function convertTools( ): BuiltOpenAICompletionTools { const adaptedTools = tools.map(tool => { const strict = !NO_STRICT && compat.supportsStrictMode !== false && tool.strict !== false; - const baseParameters = tool.parameters as unknown as Record; + const baseParameters = toolWireSchema(tool); const adapted = adaptSchemaForStrict(baseParameters, strict); return { tool, diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index f514c6964..241040092 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -41,7 +41,7 @@ import { import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot"; import { notifyProviderResponse } from "../utils/provider-response"; import { callWithCopilotModelRetry } from "../utils/retry"; -import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses } from "../utils/schema"; +import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema"; import { wrapFetchForSseDebug } from "../utils/sse-debug"; import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice"; import { @@ -587,7 +587,7 @@ export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"o } as unknown as OpenAITool; } const strict = !NO_STRICT && strictMode && tool.strict !== false; - const baseParameters = tool.parameters as unknown as Record; + const baseParameters = toolWireSchema(tool); const responseParameters = sanitizeSchemaForOpenAIResponses(baseParameters); const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict); return { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index ec4cfca57..6c6a5f761 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,4 +1,4 @@ -import type { TSchema } from "@sinclair/typebox"; +import type { ZodType, z } from "zod/v4"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses"; @@ -537,6 +537,23 @@ export interface CursorExecHandlers { onToolResult?: CursorToolResultHandler; } +/** + * Plain JSON Schema document used by extension-authored tools (legacy TypeBox + * emits this shape). Distinguished from Zod at runtime via {@link isZodSchema}. + */ +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). + */ +export type TSchema = ZodType | TJsonSchema; + +/** Resolve parameter types for tool execution / handlers. */ +export type Static = S extends ZodType ? z.infer : unknown; + export interface Tool { name: string; description: string; diff --git a/packages/ai/src/utils/discovery/antigravity.ts b/packages/ai/src/utils/discovery/antigravity.ts index 32489f979..454920126 100644 --- a/packages/ai/src/utils/discovery/antigravity.ts +++ b/packages/ai/src/utils/discovery/antigravity.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; import { getAntigravityUserAgent } from "../../providers/google-gemini-headers"; import type { Model } from "../../types"; import { toPositiveNumber } from "../../utils"; diff --git a/packages/ai/src/utils/discovery/codex.ts b/packages/ai/src/utils/discovery/codex.ts index c60c14107..1b68dbeb1 100644 --- a/packages/ai/src/utils/discovery/codex.ts +++ b/packages/ai/src/utils/discovery/codex.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; import { CODEX_BASE_URL, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../../providers/openai-codex/constants"; import type { Model } from "../../types"; import { isRecord } from "../../utils"; diff --git a/packages/ai/src/utils/discovery/cursor.ts b/packages/ai/src/utils/discovery/cursor.ts index be86b24e9..db98bbb71 100644 --- a/packages/ai/src/utils/discovery/cursor.ts +++ b/packages/ai/src/utils/discovery/cursor.ts @@ -1,6 +1,6 @@ import * as http2 from "node:http2"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { z } from "zod"; +import * as z from "zod/v4"; import { getBundledModels } from "../../models"; import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema } from "../../providers/cursor/gen/agent_pb"; import type { Model } from "../../types"; diff --git a/packages/ai/src/utils/discovery/gemini.ts b/packages/ai/src/utils/discovery/gemini.ts index adbd1fc8d..7657d10ba 100644 --- a/packages/ai/src/utils/discovery/gemini.ts +++ b/packages/ai/src/utils/discovery/gemini.ts @@ -1,5 +1,5 @@ import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai"; -import { z } from "zod"; +import * as z from "zod/v4"; import { getBundledModels } from "../../models"; import type { Model } from "../../types"; diff --git a/packages/ai/src/utils/discovery/openai-compatible.ts b/packages/ai/src/utils/discovery/openai-compatible.ts index e458ca94b..07ad8c38b 100644 --- a/packages/ai/src/utils/discovery/openai-compatible.ts +++ b/packages/ai/src/utils/discovery/openai-compatible.ts @@ -1,5 +1,5 @@ import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai"; -import { z } from "zod"; +import * as z from "zod/v4"; import type { Api, Model, Provider } from "../../types"; const MODELS_PATH = "/models"; diff --git a/packages/ai/src/utils/schema/compatibility.ts b/packages/ai/src/utils/schema/compatibility.ts index f00cf9b9b..ab1bc943f 100644 --- a/packages/ai/src/utils/schema/compatibility.ts +++ b/packages/ai/src/utils/schema/compatibility.ts @@ -1,11 +1,10 @@ -import type { AnySchema } from "ajv"; -import Ajv2020 from "ajv/dist/2020.js"; import { CCA_UNSUPPORTED_SCHEMA_FIELDS, COMBINATOR_KEYS, NON_STRUCTURAL_SCHEMA_KEYS, UNSUPPORTED_SCHEMA_FIELDS, } from "./fields"; +import { isValidJsonSchema } from "./meta-validator"; import { isJsonObject, type JsonObject } from "./types"; export type SchemaCompatibilityProvider = "openai-strict" | "google" | "cloud-code-assist-claude"; @@ -236,15 +235,6 @@ function validateGoogleNode(node: JsonObject, state: TraversalState): SchemaComp return violations; } -let cloudCodeAssistSchemaValidator: Ajv2020 | null = null; -function getCloudCodeAssistSchemaValidator(): Ajv2020 { - if (cloudCodeAssistSchemaValidator) { - return cloudCodeAssistSchemaValidator; - } - cloudCodeAssistSchemaValidator = new Ajv2020({ allErrors: true, strict: false, validateSchema: true }); - return cloudCodeAssistSchemaValidator; -} - function validateCloudCodeAssistNode(node: JsonObject, state: TraversalState): SchemaCompatibilityViolation[] { const violations: SchemaCompatibilityViolation[] = []; @@ -316,23 +306,16 @@ function validateCloudCodeAssistNode(node: JsonObject, state: TraversalState): S } function validateCloudCodeAssistSchema(schema: unknown): SchemaCompatibilityViolation[] { - try { - const valid = getCloudCodeAssistSchemaValidator().validateSchema(schema as AnySchema); - if (valid === true) { - return []; - } - return [ - createViolation( - "root", - "cca-ajv-schema-validation", - "Cloud Code Assist schema is not a valid JSON Schema (AJV 2020)", - ), - ]; - } catch { - return [ - createViolation("root", "cca-ajv-schema-validation", "Cloud Code Assist schema validation threw unexpectedly"), - ]; + if (isValidJsonSchema(schema)) { + return []; } + return [ + createViolation( + "root", + "cca-meta-schema-validation", + "Cloud Code Assist schema is not a structurally valid JSON Schema", + ), + ]; } export function validateSchemaCompatibility( diff --git a/packages/ai/src/utils/schema/from-typebox.ts b/packages/ai/src/utils/schema/from-typebox.ts new file mode 100644 index 000000000..45589359e --- /dev/null +++ b/packages/ai/src/utils/schema/from-typebox.ts @@ -0,0 +1,34 @@ +/** + * Convert a plain JSON Schema object (OpenAPI / draft-07 style) into a Zod schema. + * + * Internal validation runs against Zod; parameters may be authored as Zod directly + * or supplied as JSON Schema (legacy extension payloads). This helper turns JSON + * Schema into Zod once at validation boundary and caches by object identity. + * + * Delegates to `z.fromJSONSchema` — no dedicated TypeBox dependency at runtime. + */ + +import { type ZodType, z } from "zod/v4"; + +/** WeakMap cache so repeated registrations of the same JSON Schema object reuse the Zod result. */ +const cache = new WeakMap(); + +/** + * Convert JSON Schema (plain object) to a Zod schema. Returns a cached + * value when called repeatedly with the same source object. + */ +export function fromTypeBox(schema: Record | unknown): ZodType { + if (typeof schema === "object" && schema !== null) { + const cached = cache.get(schema as object); + if (cached) return cached; + // Pass the schema through Zod's JSON Schema importer. We pass through + // any non-standard keys; Zod silently ignores unknown keywords. + const zodSchema = z.fromJSONSchema(schema as Parameters[0]) as ZodType; + cache.set(schema as object, zodSchema); + return zodSchema; + } + // Defensive fallback — the validator only ever calls this with object + // schemas, but if someone hands us a scalar we return an `unknown` Zod + // schema so callers don't crash mid-pipeline. + return z.unknown() as unknown as ZodType; +} diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts index 3771a5ef0..ba31d6fa5 100644 --- a/packages/ai/src/utils/schema/index.ts +++ b/packages/ai/src/utils/schema/index.ts @@ -3,7 +3,10 @@ export * from "./compatibility"; export * from "./dereference"; export * from "./equality"; export * from "./fields"; +export * from "./from-typebox"; +export * from "./meta-validator"; export * from "./normalize-cca"; export * from "./sanitize-google"; export * from "./strict-mode"; export * from "./types"; +export * from "./wire"; diff --git a/packages/ai/src/utils/schema/meta-validator.ts b/packages/ai/src/utils/schema/meta-validator.ts new file mode 100644 index 000000000..2e8376d48 --- /dev/null +++ b/packages/ai/src/utils/schema/meta-validator.ts @@ -0,0 +1,127 @@ +/** + * Hand-rolled JSON Schema meta-validator. + * + * Replaces a singleton `Ajv2020.validateSchema` call with a tiny structural + * pass that covers every JSON Schema keyword the rest of the codebase + * actually emits. The full meta-schema is not necessary because: + * + * 1. Tool schemas are authored either with Zod (validated by Zod itself) or + * TypeBox (which already constructs structurally-correct JSON Schema). + * 2. The transform pipeline in `normalize-cca.ts` / `compatibility.ts` + * mutates schemas in narrow, known ways. The meta-check just guards + * against gross structural breakage introduced by those transforms. + * + * Returns `true` if the schema is well-formed enough for downstream + * consumers, otherwise `false`. Unknown keywords are accepted (forward + * compatibility); known keywords are checked for their expected shape. + */ + +type Json = unknown; + +function isPlainObject(value: Json): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]); + +/** + * Validate a single sub-schema node. Recurses into combinators, property + * maps, and items lists. Anything unrecognized is left untouched. + */ +function checkNode(node: Json, seen: WeakSet): boolean { + // Boolean schemas (`true` / `false`) are valid JSON Schema. + if (node === true || node === false) return true; + if (!isPlainObject(node)) return false; + if (seen.has(node)) return true; + seen.add(node); + + // `type` must be a known type name or a non-empty array of them. + if ("type" in node) { + const t = node.type; + if (typeof t === "string") { + if (!TYPE_NAMES.has(t)) return false; + } else if (Array.isArray(t)) { + if (t.length === 0) return false; + for (const entry of t) { + if (typeof entry !== "string" || !TYPE_NAMES.has(entry)) return false; + } + } else { + return false; + } + } + + // Combinators must be arrays of sub-schemas. + for (const key of ["anyOf", "oneOf", "allOf"] as const) { + if (key in node) { + const value = node[key]; + if (!Array.isArray(value)) return false; + for (const branch of value) { + if (!checkNode(branch, seen)) return false; + } + } + } + + // `not` is a single sub-schema. + if ("not" in node && !checkNode(node.not, seen)) return false; + + // `properties` / `patternProperties` are objects of sub-schemas. + for (const key of ["properties", "patternProperties", "$defs", "definitions"] as const) { + if (key in node) { + const map = node[key]; + if (!isPlainObject(map)) return false; + for (const sub of Object.values(map)) { + if (!checkNode(sub, seen)) return false; + } + } + } + + // `required` is an array of unique strings. + if ("required" in node) { + const value = node.required; + if (!Array.isArray(value)) return false; + for (const entry of value) { + if (typeof entry !== "string") return false; + } + } + + // `items` may be a sub-schema (single) or an array of sub-schemas (tuple). + if ("items" in node) { + const items = node.items; + if (Array.isArray(items)) { + for (const entry of items) { + if (!checkNode(entry, seen)) return false; + } + } else if (!checkNode(items, seen)) return false; + } + + // `additionalProperties` may be boolean or sub-schema. + if ("additionalProperties" in node) { + const value = node.additionalProperties; + if (typeof value !== "boolean" && !checkNode(value, seen)) return false; + } + + // `additionalItems` follows the same rule. + if ("additionalItems" in node) { + const value = node.additionalItems; + if (typeof value !== "boolean" && !checkNode(value, seen)) return false; + } + + // `enum` must be a non-empty array. + if ("enum" in node) { + if (!Array.isArray(node.enum) || node.enum.length === 0) return false; + } + + return true; +} + +/** + * Validate that `schema` is structurally a valid JSON Schema (subset). Used + * in CCA-claude validation where the full AJV meta-check used to live. + */ +export function isValidJsonSchema(schema: unknown): boolean { + try { + return checkNode(schema, new WeakSet()); + } catch { + return false; + } +} diff --git a/packages/ai/src/utils/schema/normalize-cca.ts b/packages/ai/src/utils/schema/normalize-cca.ts index c12a9fc0a..a6bc5b79c 100644 --- a/packages/ai/src/utils/schema/normalize-cca.ts +++ b/packages/ai/src/utils/schema/normalize-cca.ts @@ -1,8 +1,7 @@ import { logger } from "@oh-my-pi/pi-utils"; -import type { AnySchema } from "ajv"; -import Ajv2020 from "ajv/dist/2020.js"; import { areJsonValuesEqual, mergePropertySchemas } from "./equality"; import { CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS } from "./fields"; +import { isValidJsonSchema } from "./meta-validator"; import { sanitizeSchemaForCCA } from "./sanitize-google"; import type { JsonObject } from "./types"; import { isJsonObject } from "./types"; @@ -383,35 +382,14 @@ function normalizeNullablePropertiesForCloudCodeAssist( return extractNullableUnionSchema(normalized); } -/** - * Lazy singleton AJV instance. Only used for validateSchema() — never compile(). - * Do NOT use compile() on this instance: compiled schemas accumulate and leak memory. - * Note: Not thread-safe if Bun workers share this module. - */ -let cloudCodeAssistSchemaValidator: Ajv2020 | null = null; -function getCloudCodeAssistSchemaValidator(): Ajv2020 { - if (cloudCodeAssistSchemaValidator) { - return cloudCodeAssistSchemaValidator; - } - - cloudCodeAssistSchemaValidator = new Ajv2020({ - allErrors: true, - strict: false, - validateSchema: true, - }); - return cloudCodeAssistSchemaValidator; -} - /** * Keep validation synchronous in this request path. + * Replaces the previous AJV-based meta-schema check with a tiny + * structural validator that catches the failure modes the CCA pipeline + * actually produces. */ function isValidCCASchema(schema: unknown): boolean { - try { - const result = getCloudCodeAssistSchemaValidator().validateSchema(schema as AnySchema); - return typeof result === "boolean" ? result : false; - } catch { - return false; - } + return isValidJsonSchema(schema); } /** See COMBINATOR_KEYS in fields.ts — CCA forbids all three combiners. */ diff --git a/packages/ai/src/utils/schema/strict-mode.ts b/packages/ai/src/utils/schema/strict-mode.ts index ae350de7d..3b76e514c 100644 --- a/packages/ai/src/utils/schema/strict-mode.ts +++ b/packages/ai/src/utils/schema/strict-mode.ts @@ -1,5 +1,5 @@ import { $flag } from "@oh-my-pi/pi-utils"; -import { type TUnsafe, Type } from "@sinclair/typebox"; +import { type ZodType, z } from "zod/v4"; import { areJsonValuesEqual } from "./equality"; import { COMBINATOR_KEYS, NON_STRUCTURAL_SCHEMA_KEYS } from "./fields"; import { isJsonObject } from "./types"; @@ -13,19 +13,27 @@ import { isJsonObject } from "./types"; * description: "The operation to perform" * }); * - * type Operation = Static; // "add" | "subtract" | "multiply" | "divide" + * type Operation = z.infer; // "add" | "subtract" | ... */ export function StringEnum( values: T, options?: { description?: string; default?: T[number]; examples?: readonly T[number][] }, -): TUnsafe { - return Type.Unsafe({ - type: "string", - enum: values as unknown as string[], - ...(options?.description && { description: options.description }), - ...(options?.default && { default: options.default }), - ...(options?.examples && { examples: options.examples }), - }); +): ZodType { + if (values.length === 0) { + throw new Error("StringEnum requires at least one allowed value"); + } + const tuple = values as unknown as [string, ...string[]]; + let schema: z.ZodTypeAny = z.enum(tuple); + if (options?.description) { + schema = schema.describe(options.description); + } + if (options?.default !== undefined) { + schema = schema.default(options.default); + } + if (options?.examples?.length) { + schema = schema.meta({ examples: [...options.examples] }); + } + return schema as ZodType; } export const NO_STRICT = $flag("PI_NO_STRICT"); diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts new file mode 100644 index 000000000..74f85d15a --- /dev/null +++ b/packages/ai/src/utils/schema/wire.ts @@ -0,0 +1,114 @@ +/** + * Compute the wire (JSON Schema) representation of a tool's parameters and + * convert TypeBox-style schemas into Zod for internal validation. + * + * 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) — used as-is. + * + * Both are normalized at the boundary so providers always receive JSON Schema + * and the validator always runs against Zod. + */ + +// 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"; +import type { Tool, TSchema } from "../../types"; + +/** True when `value` is a Zod schema instance. */ +export function isZodSchema(value: unknown): value is ZodType { + return ( + typeof value === "object" && + value !== null && + // Zod v4 instances expose a `_zod` internal property with a `def` object. + // Tagging on this marker keeps the check stable across Zod minor versions. + // (`_zod` is part of Zod's documented internal contract used by introspection.) + // We avoid checking constructor name because Zod ships multiple variants + // (`ZodObject`, `ZodOptional`, etc.) and a tagged-union style check would + // have to enumerate them all. + "_zod" in value && + typeof (value as { _zod?: { def?: unknown } })._zod === "object" + ); +} + +/** WeakMap cache: Zod schema instance -> JSON Schema object. */ +const wireCache = new WeakMap>(); + +/** + * Post-process Zod-emitted JSON Schema so it matches the wire shape providers + * already expect from TypeBox-authored tools: + * + * - Drop the `$schema` URL (providers parse the body, not the metadata). + * - Make fields with a `default` non-required (TypeBox/JSON-Schema semantics + * treat defaulted fields as optional; Zod inverts this and keeps them + * required at the input boundary, then materializes the default). + * - Strip the noisy safe-integer bounds Zod injects for `z.number().int()`. + */ +function postProcess(schema: Record): Record { + delete schema.$schema; + walk(schema); + return schema; +} + +const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER; +const SAFE_INTEGER_MIN = Number.MIN_SAFE_INTEGER; + +function walk(node: unknown): void { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + + // Drop noise injected for `z.number().int()`. + if (obj.type === "integer") { + if (obj.minimum === SAFE_INTEGER_MIN) delete obj.minimum; + if (obj.maximum === SAFE_INTEGER_MAX) delete obj.maximum; + } + + // Make defaulted properties non-required. + if (Array.isArray(obj.required) && obj.properties && typeof obj.properties === "object") { + const properties = obj.properties as Record; + const required = obj.required as string[]; + const filtered = required.filter(name => { + const propertySchema = properties[name]; + if (!propertySchema || typeof propertySchema !== "object") return true; + return !("default" in (propertySchema as Record)); + }); + if (filtered.length !== required.length) { + if (filtered.length === 0) { + delete obj.required; + } else { + obj.required = filtered; + } + } + } + + for (const child of Object.values(obj)) walk(child); +} + +/** Convert a Zod schema into the JSON Schema shape providers consume. */ +export function zodToWireSchema(schema: ZodType): Record { + let json = wireCache.get(schema); + if (json) return json; + // `target: "draft-7"` keeps the output compatible with downstream sanitizers + // (OpenAI strict, Google, Anthropic CCA) that already understand draft-07. + const raw = z.toJSONSchema(schema, { target: "draft-7" }) as Record; + json = postProcess(raw); + wireCache.set(schema, json); + return json; +} + +/** + * 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 + * JSON Schema parameters are returned unchanged. + */ +export function toolWireSchema(tool: Tool): Record { + const params: TSchema = tool.parameters; + if (isZodSchema(params)) { + return zodToWireSchema(params); + } + return params as unknown as Record; +} diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 928a2add2..9ce7017b2 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -1,7 +1,9 @@ import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; +import { type ZodType, z } from "zod/v4"; +import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import type { Tool, ToolCall } from "../types"; +import { fromTypeBox } from "./schema/from-typebox"; +import { isZodSchema, zodToWireSchema } from "./schema/wire"; // ============================================================================ // Type Coercion Utilities @@ -12,10 +14,11 @@ import type { Tool, ToolCall } from "../types"; // example, an array parameter might arrive as `"[1, 2, 3]"` instead of `[1, 2, 3]`. // // Rather than rejecting these outright, we attempt automatic coercion: -// 1. AJV validates the arguments and reports type errors +// 1. Validate against the tool's schema (Zod, derived from TypeBox when the +// tool was authored with TypeBox). // 2. For each type error where the actual value is a string, we check if -// parsing it as JSON yields a value matching the expected type -// 3. If so, we replace the string with the parsed value and re-validate +// parsing it as JSON yields a value matching the expected type. +// 3. If so, we replace the string with the parsed value and re-validate. // // This is intentionally conservative: we only parse strings that look like // valid JSON literals (objects, arrays, booleans, null, numbers) and only @@ -28,19 +31,6 @@ const JSON_NUMBER_PATTERN = /^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; /** Regex matching numeric strings (allows leading zeros) */ const NUMERIC_STRING_PATTERN = /^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; -/** - * Normalizes AJV's `params.type` into a consistent string array. - * AJV may report the expected type as a single string or an array of strings - * (for union types like `["string", "null"]`). - */ -function normalizeExpectedTypes(typeParam: unknown): string[] { - if (typeof typeParam === "string") return [typeParam]; - if (Array.isArray(typeParam)) { - return typeParam.filter((entry): entry is string => typeof entry === "string"); - } - return []; -} - /** * Checks if a value matches any of the expected JSON Schema types. * Used to verify that a parsed JSON value is actually what the schema wants. @@ -198,6 +188,7 @@ function cleanLiteralEscapes(value: string): string { } return result; } + /** * Escape raw control characters (0x00–0x1F) that appear *inside* JSON string * literals. LLMs sometimes emit literal newlines/tabs/etc. inside string @@ -347,9 +338,9 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: try { const parsed = JSON.parse(trimmed) as unknown; // If the string was "null", we parsed it to actual null. - // Accept this even if null isn't in expectedTypes - the LLM meant "no value". + // Accept this even if null isn't in expectedTypes — the LLM meant "no value". // normalizeOptionalNullsForSchema will strip it from optional fields, and - // AJV will correctly error on required fields. + // the validator will correctly error on required fields. if (parsed === null && trimmed === "null") { return { value: null, changed: true }; } @@ -391,10 +382,18 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: // JSON Pointer Utilities (RFC 6901) // ============================================================================ // -// AJV reports error locations using JSON Pointer syntax (e.g., `/foo/0/bar`). -// These utilities allow reading and writing values at those paths. +// Internally we still address error locations using JSON Pointer syntax +// (e.g., `/foo/0/bar`). These utilities let coercion read and write values at +// those paths regardless of whether the original error came from Zod or +// from JSON-Schema-shaped normalization. // ============================================================================ +/** Encode a structured Zod issue path as a JSON Pointer. */ +function pathToPointer(path: ReadonlyArray): string { + if (path.length === 0) return ""; + return `/${path.map(seg => String(seg).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`; +} + /** * Decodes a JSON Pointer string into path segments. * Handles RFC 6901 escape sequences: ~1 -> /, ~0 -> ~ @@ -469,6 +468,30 @@ function setValueAtPointer(root: unknown, pointer: string, value: unknown): unkn return root; } +// ============================================================================ +// JSON-Schema-driven normalization passes (LLM quirks). +// ============================================================================ + +/** + * Resolve a JSON-Schema branch (used inside `anyOf`/`oneOf`) into a Zod + * schema we can probe for branch-membership during nullable-strip + * normalization. Cached so repeated traversals of the same schema reuse the + * compiled Zod schema. + */ +const branchZodCache = new WeakMap(); +function branchAsZod(branch: unknown): ZodType | null { + if (!branch || typeof branch !== "object") return null; + let cached = branchZodCache.get(branch as object); + if (cached) return cached; + try { + cached = z.fromJSONSchema(branch as Parameters[0]) as ZodType; + } catch { + return null; + } + branchZodCache.set(branch as object, cached); + return cached; +} + function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { value: unknown; changed: boolean } { if (value === null || value === undefined) return { value, changed: false }; if (schema === null || typeof schema !== "object") return { value, changed: false }; @@ -485,13 +508,9 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val const normalized = normalizeOptionalNullsForSchema(branch, value); if (!normalized.changed) continue; - try { - const validateBranch = ajv.compile(branch); - if (validateBranch(normalized.value)) { - return normalized; - } - } catch { - // Ignore branch-level compilation/validation errors and keep scanning. + const branchSchema = branchAsZod(branch); + if (branchSchema?.safeParse(normalized.value).success) { + return normalized; } if (!changedCandidate) { @@ -542,8 +561,7 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val // Coerce string → number/integer when the schema branch declares those types. // This fixes anyOf:[{type:"number"},{type:"null"}] (i.e. Optional) where - // AJV reports an "anyOf" error rather than a "type" error, bypassing - // coerceArgsFromErrors which only handles keyword:"type" errors. + // the validator reports an "anyOf" error rather than a "type" error. if ((schemaObject.type === "number" || schemaObject.type === "integer") && typeof value === "string") { return tryParseNumberString(value, [schemaObject.type as string]); } @@ -626,83 +644,145 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val return { value: changed ? nextValue : value, changed }; } +// ============================================================================ +// Zod issue → coercion bridge +// ============================================================================ + +interface FlatIssue { + keyword: "type" | "other"; + instancePath: string; + expectedTypes: string[]; +} + +/** + * Translate the Zod expected-type marker into the JSON-Schema type name our + * coercion helpers already understand. + */ +function mapZodExpectedToJsonSchemaType(expected: unknown): string | null { + if (typeof expected !== "string") return null; + switch (expected) { + case "string": + case "number": + case "boolean": + case "array": + case "object": + case "null": + return expected; + case "int": + case "bigint": + return "integer"; + case "nan": + return "number"; + default: + return null; + } +} + +/** + * Flatten Zod issues into a list of (path, expected-types) records suitable + * for the coercion pass. Recurses through `invalid_union` so each inner + * candidate produces independent coercion attempts. + */ +function flattenIssues(issues: ReadonlyArray): FlatIssue[] { + const out: FlatIssue[] = []; + const walk = (issue: ZodIssue, prefix: ReadonlyArray): void => { + const fullPath = prefix.length === 0 ? issue.path : [...prefix, ...issue.path]; + if (issue.code === "invalid_type") { + const mapped = mapZodExpectedToJsonSchemaType((issue as { expected?: unknown }).expected); + if (mapped) { + out.push({ keyword: "type", instancePath: pathToPointer(fullPath), expectedTypes: [mapped] }); + return; + } + } + if (issue.code === "invalid_union") { + const inner = (issue as unknown as { errors?: ReadonlyArray> }).errors; + if (inner) { + for (const branch of inner) { + for (const child of branch) { + walk(child, fullPath); + } + } + } + return; + } + out.push({ keyword: "other", instancePath: pathToPointer(fullPath), expectedTypes: [] }); + }; + for (const issue of issues) walk(issue, []); + return out; +} + /** * Attempts to fix type errors by parsing JSON-encoded strings. * - * When AJV reports type errors, this function checks if the offending values - * are strings that contain valid JSON matching the expected type. If so, it - * returns a new args object with those strings replaced by their parsed values. + * For each `type` issue where the offending value is a string that contains + * valid JSON matching the expected type, returns a new args object with + * those strings replaced by their parsed values. * - * The function is designed to be safe and conservative: + * The function is safe and conservative: * - Only processes "type" errors (not format, pattern, etc.) * - Only attempts coercion on string values * - Only accepts parsed results that match the expected type * - Clones the args object before mutation (copy-on-write) */ -function coerceArgsFromErrors( - args: unknown, - errors: Array<{ keyword?: string; instancePath?: string; params?: { type?: unknown } }> | null | undefined, -): { value: unknown; changed: boolean } { - if (!errors || errors.length === 0) return { value: args, changed: false }; +function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unknown; changed: boolean } { + if (issues.length === 0) return { value: args, changed: false }; let changed = false; let nextArgs: unknown = args; - for (const error of errors) { - // Only handle type mismatch errors - if (error.keyword !== "type") continue; + for (const issue of issues) { + if (issue.keyword !== "type") continue; + if (issue.expectedTypes.length === 0) continue; - const instancePath = error.instancePath ?? ""; - const expectedTypes = normalizeExpectedTypes(error.params?.type); - if (expectedTypes.length === 0) continue; - - // Get the current value at the error location - const currentValue = getValueAtPointer(nextArgs, instancePath); + const currentValue = getValueAtPointer(nextArgs, issue.instancePath); if (typeof currentValue !== "string") continue; - // Try to parse the string as JSON - const result = tryParseJsonForTypes(currentValue, expectedTypes); + const result = tryParseJsonForTypes(currentValue, issue.expectedTypes); if (!result.changed) continue; - // Clone on first modification (copy-on-write) if (!changed) { nextArgs = structuredCloneJSON(nextArgs); changed = true; } - nextArgs = setValueAtPointer(nextArgs, instancePath, result.value); + nextArgs = setValueAtPointer(nextArgs, issue.instancePath, result.value); } return { value: changed ? nextArgs : args, changed }; } -// Create a singleton AJV instance with formats (only if not in browser extension) -// AJV requires 'unsafe-eval' CSP which is not allowed in Manifest V3 -// -// Silent logger: MCP servers may declare non-standard format keywords (e.g. "uint") -// which cause Ajv to emit console.warn() with strict:false — corrupting TUI output. -const ajv = new Ajv({ - allErrors: true, - strict: false, - logger: false, -}); -addFormats(ajv); +// ============================================================================ +// Public API +// ============================================================================ -// Cache compiled validators by schema object identity to avoid -// re-compiling the same tool schema on every call. -const compiledSchemaCache = new WeakMap(); -function compileSchema(schema: object): import("ajv").ValidateFunction { - let validate = compiledSchemaCache.get(schema); - if (!validate) { - validate = ajv.compile(schema); - compiledSchemaCache.set(schema, validate); - } - return validate; +interface ValidationContext { + zod: ZodType; + json: Record; } -const MAX_TYPE_COERCION_PASSES = 5; +/** + * Cache the (zod, json) pair derived from a tool's parameters schema. + * Keyed by the parameters object identity, which is stable across tool + * registrations. + */ +const validationContextCache = new WeakMap(); +function getValidationContext(tool: Tool): ValidationContext { + const params = tool.parameters as object; + let ctx = validationContextCache.get(params); + if (ctx) return ctx; + if (isZodSchema(params)) { + ctx = { zod: params, json: zodToWireSchema(params) }; + } else { + const json = params as unknown as Record; + ctx = { zod: fromTypeBox(json), json }; + } + validationContextCache.set(params, ctx); + return ctx; +} + +const MAX_COERCION_PASSES = 5; /** - * Finds a tool by name and validates the tool call arguments against its TypeBox schema + * Finds a tool by name and validates the tool call arguments against its schema. * @param tools Array of tool definitions * @param toolCall The tool call from the LLM * @returns The validated arguments @@ -717,57 +797,54 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a } /** - * Validates tool call arguments against the tool's TypeBox schema - * @param tool The tool definition with TypeBox schema - * @param toolCall The tool call from the LLM - * @returns The validated arguments - * @throws Error with formatted message if validation fails + * Validates tool call arguments against the tool's schema (Zod, or TypeBox + * lifted into Zod). Applies LLM-quirk coercions (numeric strings, JSON-string + * containers, null-for-optional, null-for-default) before declaring failure. + * + * @throws Error with a formatted message when validation cannot be reconciled. */ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] { const originalArgs = toolCall.arguments; + const { zod, json } = getValidationContext(tool); - const validate = compileSchema(tool.parameters); - - // Always normalize first - strip null and string "null" from optional fields. - // This handles LLM outputting string "null" to mean "no value" even when - // validation would pass (e.g., optional string field where "null" is a valid string). + // Always normalize first — strip null and string "null" from optional + // fields and substitute defaults. Handles LLM outputting string "null" + // to mean "no value" even when validation would otherwise pass. let normalizedArgs: unknown = originalArgs; let changed = false; - - const initialNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs); + const initialNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs); if (initialNormalization.changed) { normalizedArgs = initialNormalization.value; changed = true; } - // Validate after normalization - if (validate(normalizedArgs)) { - return normalizedArgs as ToolCall["arguments"]; - } + let result = zod.safeParse(normalizedArgs); + if (result.success) return result.data as ToolCall["arguments"]; - for (let pass = 0; pass < MAX_TYPE_COERCION_PASSES; pass += 1) { - const coercion = coerceArgsFromErrors(normalizedArgs, validate.errors); + for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) { + const flat = flattenIssues(result.error.issues); + const coercion = coerceArgsFromIssues(normalizedArgs, flat); if (!coercion.changed) break; normalizedArgs = coercion.value; changed = true; - const nullNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs); + const nullNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs); if (nullNormalization.changed) { normalizedArgs = nullNormalization.value; } - if (validate(normalizedArgs)) { - return normalizedArgs as ToolCall["arguments"]; - } + result = zod.safeParse(normalizedArgs); + if (result.success) return result.data as ToolCall["arguments"]; } - // Format validation errors nicely + // Format validation errors nicely. The header phrase is asserted by + // existing tests; the detailed body is informational. const errors = - validate.errors - ?.map((err: any) => { - const path = err.instancePath ? err.instancePath.substring(1) : err.params.missingProperty || "root"; - return ` - ${path}: ${err.message}`; + result.error.issues + .map(issue => { + const path = issue.path.length === 0 ? "root" : issue.path.map(seg => String(seg)).join("/"); + return ` - ${path}: ${issue.message}`; }) .join("\n") || "Unknown validation error"; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 60b51e6f1..cc38ec012 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -18,8 +18,7 @@ import { stripClaudeToolPrefix, } from "@oh-my-pi/pi-ai/providers/anthropic"; import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; -import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; -import type { TSchema } from "@sinclair/typebox"; +import type { Context, Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types"; import { withEnv } from "./helpers"; const ANTHROPIC_MODEL: Model<"anthropic-messages"> = { @@ -345,10 +344,14 @@ describe("Anthropic request fingerprint alignment", () => { patternProperties: { "^[A-Za-z_][A-Za-z0-9_]*$": { type: "string" }, }, + propertyNames: { + type: "string", + pattern: "^[A-Za-z_][A-Za-z0-9_]*$", + }, }, }, required: ["target"], - } as unknown as TSchema, + } as TJsonSchema, }, ]; @@ -373,7 +376,11 @@ describe("Anthropic request fingerprint alignment", () => { type?: string; items?: { additionalProperties?: boolean; required?: string[] }; }; - const env = properties.env as { additionalProperties?: boolean; patternProperties?: unknown }; + const env = properties.env as { + additionalProperties?: boolean; + patternProperties?: unknown; + propertyNames?: unknown; + }; expect(inputSchema?.additionalProperties).toBe(false); expect(inputSchema?.required).toEqual(["target"]); @@ -384,6 +391,7 @@ describe("Anthropic request fingerprint alignment", () => { expect(target).not.toHaveProperty("patternProperties"); expect(env.additionalProperties).toBe(false); expect(env).not.toHaveProperty("patternProperties"); + expect(env).not.toHaveProperty("propertyNames"); expect(inputSchema?.properties).toHaveProperty("target"); expect(originalNestedSchema).not.toHaveProperty("additionalProperties"); expect(originalNestedSchema).toHaveProperty("patternProperties"); @@ -410,7 +418,7 @@ describe("Anthropic request fingerprint alignment", () => { }, }, required: ["sub"], - } as unknown as TSchema, + } as TJsonSchema, }, ]; @@ -449,7 +457,7 @@ describe("Anthropic request fingerprint alignment", () => { }, }, required: ["block"], - } as unknown as TSchema, + } as TJsonSchema, }, ]; @@ -478,7 +486,7 @@ describe("Anthropic request fingerprint alignment", () => { type: "object", properties: { requiredValue: { type: "string" } }, required: ["requiredValue"], - } as unknown as TSchema, + } as TJsonSchema, })), ...(["write", "grep", "read", "task", "todo_write", "web_search", "ast_grep"] as const).map(name => ({ name, @@ -488,7 +496,7 @@ describe("Anthropic request fingerprint alignment", () => { type: "object", properties: { requiredValue: { type: "string" } }, required: ["requiredValue"], - } as unknown as TSchema, + } as TJsonSchema, })), ]; @@ -520,7 +528,7 @@ describe("Anthropic request fingerprint alignment", () => { type: "object", properties: { requiredValue: { type: "string" } }, required: ["requiredValue"], - } as unknown as TSchema, + } as TJsonSchema, }, { name: "python", @@ -530,7 +538,7 @@ describe("Anthropic request fingerprint alignment", () => { type: "object", properties: { requiredValue: { type: "string" } }, required: ["requiredValue"], - } as unknown as TSchema, + } as TJsonSchema, }, { name: "write", @@ -539,7 +547,7 @@ describe("Anthropic request fingerprint alignment", () => { type: "object", properties: { requiredValue: { type: "string" } }, required: ["requiredValue"], - } as unknown as TSchema, + } as TJsonSchema, }, { name: "grep", @@ -549,7 +557,7 @@ describe("Anthropic request fingerprint alignment", () => { type: "object", properties: { requiredValue: { type: "string" } }, required: ["requiredValue"], - } as unknown as TSchema, + } as TJsonSchema, }, ]; diff --git a/packages/ai/test/anthropic-stream-envelope.test.ts b/packages/ai/test/anthropic-stream-envelope.test.ts index 0b38d4f23..19be1b7c8 100644 --- a/packages/ai/test/anthropic-stream-envelope.test.ts +++ b/packages/ai/test/anthropic-stream-envelope.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { Messages } from "@anthropic-ai/sdk/resources/messages/messages"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { streamAnthropic } from "../src/providers/anthropic"; import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types"; @@ -294,7 +294,7 @@ describe("anthropic stream envelope handling", () => { name: "edit", description: "Edit a value", strict: true, - parameters: Type.Object({ query: Type.String() }), + parameters: z.object({ query: z.string() }), }, ], }; @@ -350,7 +350,7 @@ describe("anthropic stream envelope handling", () => { name: "edit", description: "Edit a value", strict: true, - parameters: Type.Object({ query: Type.String() }), + parameters: z.object({ query: z.string() }), }, ], }; @@ -486,7 +486,7 @@ describe("anthropic stream envelope handling", () => { { name: "lookup_weather", description: "Lookup weather", - parameters: Type.Object({ city: Type.String() }), + parameters: z.object({ city: z.string() }), }, ], }; diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index 3665712e4..4d04583c2 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -14,8 +14,8 @@ import { processResponsesStream, } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; import type { AssistantMessage, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; import type { ResponseStreamEvent } from "openai/resources/responses/responses"; +import * as z from "zod/v4"; const GRAMMAR = [ "// top-level comment", @@ -63,14 +63,14 @@ const editTool: Tool = { name: "edit", customWireName: "apply_patch", description: "edit files", - parameters: Type.Object({ input: Type.String() }), + parameters: z.object({ input: z.string() }), customFormat: { syntax: "lark", definition: GRAMMAR }, }; const plainTool: Tool = { name: "read_file", description: "read a file", - parameters: Type.Object({ path: Type.String() }), + parameters: z.object({ path: z.string() }), }; const unionBranches = [ @@ -404,13 +404,13 @@ describe("dispatcher wire-name matching", () => { name: "edit", customWireName: "apply_patch", description: "edit files", - parameters: Type.Object({ input: Type.String() }), + parameters: z.object({ input: z.string() }), customFormat: { syntax: "lark", definition: GRAMMAR }, }; const readTool: Tool = { name: "read_file", description: "read", - parameters: Type.Object({ path: Type.String() }), + parameters: z.object({ path: z.string() }), }; const tools = [editLikeTool, readTool]; const toolCall = { name: "apply_patch" }; @@ -431,13 +431,13 @@ describe("dispatcher wire-name matching", () => { const nameMatch: Tool = { name: "foo", description: "", - parameters: Type.Object({}), + parameters: z.object({}), }; const wireMatch: Tool & { customWireName: string } = { name: "bar", customWireName: "foo", description: "", - parameters: Type.Object({}), + parameters: z.object({}), }; const tools = [wireMatch, nameMatch]; // wireMatch listed first const toolCall = { name: "foo" }; diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index 213e02caa..51bcfc5ff 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -1,13 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; import { buildRequest, parseGeminiCliCredentials, shouldRefreshGeminiCliCredentials, streamGoogleGeminiCli, } from "../src/providers/google-gemini-cli"; -import type { Context, Model } from "../src/types"; +import type { Context, Model, TJsonSchema } from "../src/types"; import { getOAuthApiKey } from "../src/utils/oauth"; function createModel(provider: "google-gemini-cli" | "google-antigravity"): Model<"google-gemini-cli"> { @@ -183,7 +182,7 @@ describe("Google Gemini CLI alignment", () => { }, }, required: ["rules"], - } as unknown as TSchema, + } as TJsonSchema, }, ], }; diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index befdc6360..d704801a4 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "bun:test"; import { convertTools } from "@oh-my-pi/pi-ai/providers/google-shared"; -import type { Model, Tool } from "@oh-my-pi/pi-ai/types"; +import type { Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types"; import { sanitizeSchemaForCCA, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema"; -import type { TSchema } from "@sinclair/typebox"; function createModel(id: string): Model<"google-gemini-cli"> { return { @@ -58,7 +57,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }, }, required: ["value"], - } as unknown as TSchema; + } as TJsonSchema; const tools: Tool[] = [{ name: "test_tool", description: "Test tool", parameters }]; const model = createModel("claude-sonnet-4-5"); @@ -88,7 +87,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }, }, required: ["lines"], - } as unknown as TSchema; + } as TJsonSchema; const tools: Tool[] = [{ name: "test_tool", description: "Test tool", parameters }]; const claudeModel = createModel("claude-sonnet-4-5"); const geminiModel = createModel("gemini-2.5-pro"); @@ -118,7 +117,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { expect(claudeDeclaration.parametersJsonSchema).toBeUndefined(); expect( (geminiDeclaration.parametersJsonSchema as { properties?: Record })?.properties?.lines, - ).toEqual(parameters.properties.lines); + ).toEqual((parameters as { properties: { lines: unknown } }).properties.lines); }); it("collapses mixed anyOf with shared metadata for edit-style lines fields", () => { @@ -145,7 +144,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }, }, }, - } as unknown as TSchema; + } as TJsonSchema; const tools: Tool[] = [{ name: "edit", description: "Edit tool", parameters }]; const model = createModel("claude-sonnet-4-5"); @@ -180,7 +179,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }, }, }, - } as unknown as TSchema; + } as TJsonSchema; const tools: Tool[] = [{ name: "todo_write", description: "Todo tool", parameters }]; const model = createModel("claude-sonnet-4-5"); @@ -207,7 +206,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }, }, required: ["value"], - } as unknown as TSchema; + } as TJsonSchema; const tools: Tool[] = [{ name: "test_tool", description: "Test tool", parameters }]; const claudeModel = createModel("claude-sonnet-4-5"); const geminiModel = createModel("gemini-2.5-pro"); @@ -231,7 +230,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { expect(JSON.stringify(claudeDeclaration.parameters)).not.toContain('"anyOf"'); expect( (geminiDeclaration.parametersJsonSchema as { properties?: Record })?.properties?.value, - ).toEqual(parameters.properties.value); + ).toEqual((parameters as { properties: { value: unknown } }).properties.value); }); it("falls back to minimal object schema when non-null unresolved unions remain for CCA Claude", () => { @@ -243,7 +242,7 @@ describe("Cloud Code Assist Claude tool schema conversion", () => { }, }, required: ["value"], - } as unknown as TSchema; + } as TJsonSchema; const tools: Tool[] = [{ name: "test_tool", description: "Test tool", parameters }]; const claudeModel = createModel("claude-sonnet-4-5"); diff --git a/packages/ai/test/handoff.test.ts b/packages/ai/test/handoff.test.ts index 9f30e1397..8745b6b4f 100644 --- a/packages/ai/test/handoff.test.ts +++ b/packages/ai/test/handoff.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, AssistantMessage, Context, Message, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { e2eApiKey } from "./oauth"; // Tool for testing -const weatherSchema = Type.Object({ - location: Type.String({ description: "City name" }), +const weatherSchema = z.object({ + location: z.string().describe("City name"), }); const weatherTool: Tool = { diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 2e01860cd..47fdb7c0e 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import type { Api, Context, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { complete, getBundledModel } from "@oh-my-pi/pi-ai"; import type { OptionsForApi } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) @@ -37,7 +37,7 @@ async function handleToolWithImageResult(model: Model, o const base64Image = imageBuffer.toBase64(); // Define a tool that returns only an image (no text) - const getImageSchema = Type.Object({}); + const getImageSchema = z.object({}); const getImageTool: Tool = { name: "get_circle", description: "Returns a circle image for visualization", @@ -125,7 +125,7 @@ async function handleToolWithTextAndImageResult(model: Model = { name: "get_circle_with_description", description: "Returns a circle image with a text description", diff --git a/packages/ai/test/issue-827-repro.test.ts b/packages/ai/test/issue-827-repro.test.ts index ace904a8d..70094e71e 100644 --- a/packages/ai/test/issue-827-repro.test.ts +++ b/packages/ai/test/issue-827-repro.test.ts @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; const originalFetch = global.fetch; @@ -22,7 +22,7 @@ afterEach(() => { const echoTool: Tool = { name: "echo", description: "Echo input", - parameters: Type.Object({ text: Type.String() }), + parameters: z.object({ text: z.string() }), }; const ctx: Context = { diff --git a/packages/ai/test/issue-945-repro.test.ts b/packages/ai/test/issue-945-repro.test.ts index 6f5e361f5..d1221c2a1 100644 --- a/packages/ai/test/issue-945-repro.test.ts +++ b/packages/ai/test/issue-945-repro.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; const originalFetch = global.fetch; @@ -13,7 +13,7 @@ afterEach(() => { const echoTool: Tool = { name: "echo", description: "Echo input", - parameters: Type.Object({ text: Type.String() }), + parameters: z.object({ text: z.string() }), }; const context: Context = { diff --git a/packages/ai/test/openai-completions-progress-chunk.test.ts b/packages/ai/test/openai-completions-progress-chunk.test.ts new file mode 100644 index 000000000..6c685a959 --- /dev/null +++ b/packages/ai/test/openai-completions-progress-chunk.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "bun:test"; +import { isOpenAICompletionsProgressChunk } from "../src/providers/openai-completions"; + +/** + * Contract: `isOpenAICompletionsProgressChunk` decides whether a streamed chunk + * resets the idle-watchdog deadline in `iterateWithIdleTimeout`. A false + * positive (counting a no-op chunk as progress) silently disables the + * watchdog and is the root cause of the z.ai/GLM-via-OpenRouter hang where + * a subagent stalled for hours with no error surfaced. A false negative is + * cheap (delays the watchdog by at most the first-event window). + */ +describe("isOpenAICompletionsProgressChunk", () => { + describe("non-progress chunks (MUST NOT reset the watchdog)", () => { + it("rejects null/non-object", () => { + expect(isOpenAICompletionsProgressChunk(null)).toBe(false); + expect(isOpenAICompletionsProgressChunk(undefined)).toBe(false); + expect(isOpenAICompletionsProgressChunk("hi")).toBe(false); + expect(isOpenAICompletionsProgressChunk(42)).toBe(false); + }); + + it("rejects empty {} keepalives", () => { + expect(isOpenAICompletionsProgressChunk({})).toBe(false); + }); + + it("rejects {choices: []} keepalives", () => { + expect(isOpenAICompletionsProgressChunk({ choices: [] })).toBe(false); + }); + + it("rejects role-only preambles", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { role: "assistant" } }], + }), + ).toBe(false); + }); + + it("rejects empty-string content deltas", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { content: "" } }], + }), + ).toBe(false); + }); + + it("rejects empty-array content deltas", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { content: [] } }], + }), + ).toBe(false); + }); + + it("rejects empty tool_calls arrays", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { tool_calls: [] } }], + }), + ).toBe(false); + }); + + it("rejects empty reasoning fields", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { reasoning: "" } }], + }), + ).toBe(false); + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { reasoning_content: "" } }], + }), + ).toBe(false); + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { reasoning_text: "" } }], + }), + ).toBe(false); + }); + }); + + describe("progress chunks (MUST reset the watchdog)", () => { + it("accepts a top-level usage chunk (terminal token report)", () => { + expect( + isOpenAICompletionsProgressChunk({ + usage: { prompt_tokens: 12, completion_tokens: 4 }, + }), + ).toBe(true); + }); + + it("accepts choice-level usage", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ usage: { prompt_tokens: 12 } }], + }), + ).toBe(true); + }); + + it("accepts finish_reason", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ finish_reason: "stop" }], + }), + ).toBe(true); + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ finish_reason: "tool_calls" }], + }), + ).toBe(true); + }); + + it("accepts text content deltas", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { content: "Hello" } }], + }), + ).toBe(true); + }); + + it("accepts array-shape content parts (Mistral-style)", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { content: [{ type: "text", text: "Hi" }] } }], + }), + ).toBe(true); + }); + + it("accepts tool call deltas", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [ + { + delta: { + tool_calls: [{ id: "call_1", function: { name: "search", arguments: "{}" } }], + }, + }, + ], + }), + ).toBe(true); + }); + + it("accepts reasoning deltas in all three field names", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { reasoning: "thinking..." } }], + }), + ).toBe(true); + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { reasoning_content: "thinking..." } }], + }), + ).toBe(true); + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { reasoning_text: "thinking..." } }], + }), + ).toBe(true); + }); + + it("accepts refusal deltas", () => { + expect( + isOpenAICompletionsProgressChunk({ + choices: [{ delta: { refusal: "I can't help with that." } }], + }), + ).toBe(true); + }); + }); +}); diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index f05a69d3f..2ed2bedce 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -3,7 +3,7 @@ import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, OpenAICompat, ProviderSessionState, Tool } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; const originalFetch = global.fetch; @@ -15,8 +15,8 @@ afterEach(() => { const testTool: Tool = { name: "echo", description: "Echo input", - parameters: Type.Object({ - text: Type.String(), + parameters: z.object({ + text: z.string(), }), }; @@ -134,8 +134,8 @@ describe("OpenAI tool strict mode", () => { { name: "dynamic_map", description: "Dynamic object map", - parameters: Type.Object({ - values: Type.Optional(Type.Record(Type.String(), Type.String())), + parameters: z.object({ + values: z.record(z.string(), z.string()).optional(), }), }, ], diff --git a/packages/ai/test/schema-strict-mode.test.ts b/packages/ai/test/schema-strict-mode.test.ts index f509e386d..d0180af0b 100644 --- a/packages/ai/test/schema-strict-mode.test.ts +++ b/packages/ai/test/schema-strict-mode.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { enforceStrictSchema, sanitizeSchemaForStrictMode, tryEnforceStrictSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { Type } from "@sinclair/typebox"; +import { + enforceStrictSchema, + sanitizeSchemaForStrictMode, + tryEnforceStrictSchema, + zodToWireSchema, +} from "@oh-my-pi/pi-ai/utils/schema"; +import * as z from "zod/v4"; describe("sanitizeSchemaForStrictMode", () => { it("infers object type, strips non-structural keywords, and converts const to enum", () => { @@ -233,12 +238,14 @@ describe("sanitizeSchemaForStrictMode", () => { describe("enforceStrictSchema", () => { it("converts optional properties to nullable schemas and requires all object keys", () => { - const schema = Type.Object({ - requiredText: Type.String(), - optionalCount: Type.Optional(Type.Number()), - }); + const schema = zodToWireSchema( + z.object({ + requiredText: z.string(), + optionalCount: z.number().optional(), + }), + ); - const strict = enforceStrictSchema(schema as unknown as Record); + const strict = enforceStrictSchema(schema); const properties = strict.properties as Record>; expect(strict.required).toEqual(["requiredText", "optionalCount"]); @@ -248,16 +255,18 @@ describe("enforceStrictSchema", () => { }); it("never emits undefined as a schema type", () => { - const schema = Type.Object({ - questions: Type.Array( - Type.Object({ - id: Type.String(), - recommended: Type.Optional(Type.Number()), - }), - ), - }); + const schema = zodToWireSchema( + z.object({ + questions: z.array( + z.object({ + id: z.string(), + recommended: z.number().optional(), + }), + ), + }), + ); - const strict = enforceStrictSchema(schema as unknown as Record); + const strict = enforceStrictSchema(schema); const serialized = JSON.stringify(strict); expect(serialized.includes('"undefined"')).toBe(false); @@ -452,27 +461,29 @@ describe("tryEnforceStrictSchema", () => { }); it("keeps shared object schemas strict-compatible after adaptation", () => { - const sharedTaskSchema = Type.Object({ - content: Type.String(), - status: Type.Optional(Type.String()), - notes: Type.Optional(Type.String()), - }); - const schema = Type.Object({ - ops: Type.Array( - Type.Union([ - Type.Object({ - op: Type.Literal("replace"), - tasks: Type.Array(sharedTaskSchema), - }), - Type.Object({ - op: Type.Literal("update"), - tasks: Type.Optional(Type.Array(sharedTaskSchema)), - }), - ]), - ), + const sharedTaskSchema = z.object({ + content: z.string(), + status: z.string().optional(), + notes: z.string().optional(), }); + const schema = zodToWireSchema( + z.object({ + ops: z.array( + z.union([ + z.object({ + op: z.literal("replace"), + tasks: z.array(sharedTaskSchema), + }), + z.object({ + op: z.literal("update"), + tasks: z.array(sharedTaskSchema).optional(), + }), + ]), + ), + }), + ); - const result = tryEnforceStrictSchema(schema as unknown as Record); + const result = tryEnforceStrictSchema(schema); const rootProperties = result.schema.properties as Record>; const opBranches = ((rootProperties.ops.items as Record).anyOf ?? []) as Array< Record diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index a7a45de82..3a8d397a1 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -8,7 +8,7 @@ import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { StringEnum } from "@oh-my-pi/pi-ai/utils/schema"; import { $which } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) @@ -35,10 +35,10 @@ function hasBedrockCredentials(): boolean { // Calculator tool definition (same as examples) // Note: Using StringEnum helper because Google's API doesn't support anyOf/const patterns -// that Type.Enum generates. Google requires { type: "string", enum: [...] } format. -const calculatorSchema = Type.Object({ - a: Type.Number({ description: "First number" }), - b: Type.Number({ description: "Second number" }), +// that some schema authors emit for string unions. Google requires { type: "string", enum: [...] } format. +const calculatorSchema = z.object({ + a: z.number().describe("First number"), + b: z.number().describe("Second number"), operation: StringEnum(["add", "subtract", "multiply", "divide"], { description: "The operation to perform. One of 'add', 'subtract', 'multiply', 'divide'.", }), diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 1af5f2c5a..da7882dec 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "bun:test"; import type { Tool, ToolCall } from "@oh-my-pi/pi-ai/types"; import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; describe("Tool argument coercion", () => { it("coerces numeric strings when schema expects number", () => { const tool: Tool = { name: "t1", description: "", - parameters: Type.Object({ timeout: Type.Number() }), + parameters: z.object({ timeout: z.number() }), }; const toolCall: ToolCall = { @@ -27,7 +27,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t2", description: "", - parameters: Type.Object({ label: Type.String() }), + parameters: z.object({ label: z.string() }), }; const toolCall: ToolCall = { @@ -46,7 +46,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t3", description: "", - parameters: Type.Object({ items: Type.Array(Type.Number()) }), + parameters: z.object({ items: z.array(z.number()) }), }; const toolCall: ToolCall = { @@ -64,7 +64,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t4", description: "", - parameters: Type.Object({ payload: Type.Object({ a: Type.Number() }) }), + parameters: z.object({ payload: z.object({ a: z.number() }) }), }; const toolCall: ToolCall = { @@ -82,7 +82,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t5", description: "", - parameters: Type.Object({ payload: Type.Object({ items: Type.Array(Type.Number()) }) }), + parameters: z.object({ payload: z.object({ items: z.array(z.number()) }) }), }; const toolCall: ToolCall = { @@ -100,11 +100,11 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t9", description: "", - parameters: Type.Object({ - a: Type.String(), - b: Type.Array( - Type.Object({ - k: Type.String(), + parameters: z.object({ + a: z.string(), + b: z.array( + z.object({ + k: z.string(), }), ), }), @@ -126,11 +126,11 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t10", description: "", - parameters: Type.Object({ - a: Type.String(), - b: Type.Array( - Type.Object({ - k: Type.String(), + parameters: z.object({ + a: z.string(), + b: z.array( + z.object({ + k: z.string(), }), ), }), @@ -152,12 +152,12 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t7", description: "", - parameters: Type.Object({ - path: Type.String(), - edits: Type.Array( - Type.Object({ - target: Type.String(), - new_content: Type.String(), + parameters: z.object({ + path: z.string(), + edits: z.array( + z.object({ + target: z.string(), + new_content: z.string(), }), ), }), @@ -180,19 +180,19 @@ describe("Tool argument coercion", () => { }); it("coerces quoted edit arrays before stripping optional null fields", () => { - const textSchema = Type.Union([Type.Array(Type.String()), Type.String()]); + const textSchema = z.union([z.array(z.string()), z.string()]); const tool: Tool = { name: "atom-like-edit", description: "", - parameters: Type.Object({ - path: Type.String(), - edits: Type.Array( - Type.Object({ - loc: Type.String(), - set: Type.Optional(textSchema), - pre: Type.Optional(textSchema), - post: Type.Optional(textSchema), - sub: Type.Optional(Type.Tuple([Type.String(), Type.String()])), + parameters: z.object({ + path: z.string(), + edits: z.array( + z.object({ + loc: z.string(), + set: textSchema.optional(), + pre: textSchema.optional(), + post: textSchema.optional(), + sub: z.tuple([z.string(), z.string()]).optional(), }), ), }), @@ -215,14 +215,14 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t16", description: "", - parameters: Type.Object({ - path: Type.String(), - edits: Type.Array( - Type.Object({ - op: Type.String(), - pos: Type.String(), - end: Type.String(), - lines: Type.Array(Type.String()), + parameters: z.object({ + path: z.string(), + edits: z.array( + z.object({ + op: z.string(), + pos: z.string(), + end: z.string(), + lines: z.array(z.string()), }), ), }), @@ -252,12 +252,12 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t8", description: "", - parameters: Type.Object({ - path: Type.String(), - edits: Type.Array( - Type.Object({ - target: Type.String(), - new_content: Type.String(), + parameters: z.object({ + path: z.string(), + edits: z.array( + z.object({ + target: z.string(), + new_content: z.string(), }), ), }), @@ -281,9 +281,9 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t11", description: "", - parameters: Type.Object({ - requiredText: Type.String(), - optionalCount: Type.Optional(Type.Number()), + parameters: z.object({ + requiredText: z.string(), + optionalCount: z.number().optional(), }), }; @@ -302,12 +302,12 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t12", description: "", - parameters: Type.Object({ - edits: Type.Array( - Type.Object({ - target: Type.String(), - pos: Type.Optional(Type.String()), - end: Type.Optional(Type.String()), + parameters: z.object({ + edits: z.array( + z.object({ + target: z.string(), + pos: z.string().optional(), + end: z.string().optional(), }), ), }), @@ -325,26 +325,26 @@ describe("Tool argument coercion", () => { }); it("drops null optional properties in anyOf object branches", () => { - const opSchema = Type.Union([ - Type.Object({ - op: Type.Literal("add_task"), - phase: Type.String(), - content: Type.String(), + const opSchema = z.union([ + z.object({ + op: z.literal("add_task"), + phase: z.string(), + content: z.string(), }), - Type.Object({ - op: Type.Literal("update"), - id: Type.String(), - status: Type.Optional(Type.String()), - content: Type.Optional(Type.String()), - notes: Type.Optional(Type.String()), + z.object({ + op: z.literal("update"), + id: z.string(), + status: z.string().optional(), + content: z.string().optional(), + notes: z.string().optional(), }), ]); const tool: Tool = { name: "t13", description: "", - parameters: Type.Object({ - ops: Type.Array(opSchema), + parameters: z.object({ + ops: z.array(opSchema), }), }; @@ -382,7 +382,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t6", description: "", - parameters: Type.Object({ timeout: Type.Number() }), + parameters: z.object({ timeout: z.number() }), }; const toolCall: ToolCall = { @@ -399,7 +399,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t14", description: "", - parameters: Type.Object({ tick_size: Type.Optional(Type.Number()) }), + parameters: z.object({ tick_size: z.number().optional() }), }; const toolCall: ToolCall = { type: "toolCall", @@ -416,7 +416,7 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t15", description: "", - parameters: Type.Object({ tick_size: Type.Optional(Type.Number()) }), + parameters: z.object({ tick_size: z.number().optional() }), }; const toolCall: ToolCall = { type: "toolCall", @@ -431,9 +431,9 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "edit-tool", description: "", - parameters: Type.Object({ - path: Type.String(), - delete: Type.Optional(Type.Boolean()), + parameters: z.object({ + path: z.string(), + delete: z.boolean().optional(), }), }; @@ -452,9 +452,9 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "edit-tool", description: "", - parameters: Type.Object({ - path: Type.String(), - move: Type.Optional(Type.String()), + parameters: z.object({ + path: z.string(), + move: z.string().optional(), }), }; @@ -473,8 +473,8 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "required-tool", description: "", - parameters: Type.Object({ - path: Type.String(), + parameters: z.object({ + path: z.string(), }), }; @@ -495,10 +495,10 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "multi-optional", description: "", - parameters: Type.Object({ - required: Type.String(), - optBool: Type.Optional(Type.Boolean()), - optString: Type.Optional(Type.String()), + parameters: z.object({ + required: z.string(), + optBool: z.boolean().optional(), + optString: z.string().optional(), }), }; @@ -517,12 +517,12 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "heal-1", description: "", - parameters: Type.Object({ - path: Type.String(), - edits: Type.Array( - Type.Object({ - target: Type.String(), - content: Type.String(), + parameters: z.object({ + path: z.string(), + edits: z.array( + z.object({ + target: z.string(), + content: z.string(), }), ), }), @@ -547,12 +547,12 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "heal-2", description: "", - parameters: Type.Object({ - path: Type.String(), - edits: Type.Array( - Type.Object({ - target: Type.String(), - content: Type.String(), + parameters: z.object({ + path: z.string(), + edits: z.array( + z.object({ + target: z.string(), + content: z.string(), }), ), }), @@ -577,8 +577,8 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "heal-esc-1", description: "", - parameters: Type.Object({ - edits: Type.Array(Type.Object({ target: Type.String(), content: Type.String() })), + parameters: z.object({ + edits: z.array(z.object({ target: z.string(), content: z.string() })), }), }; @@ -600,8 +600,8 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "heal-trail-1", description: "", - parameters: Type.Object({ - edits: Type.Array(Type.Object({ target: Type.String(), op: Type.String() })), + parameters: z.object({ + edits: z.array(z.object({ target: z.string(), op: z.string() })), }), }; @@ -623,8 +623,8 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "heal-3", description: "", - parameters: Type.Object({ - edits: Type.Array(Type.Object({ target: Type.String() })), + parameters: z.object({ + edits: z.array(z.object({ target: z.string() })), }), }; @@ -644,14 +644,14 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "todo_write_like", description: "", - parameters: Type.Object({ - phases: Type.Array( - Type.Object({ - name: Type.String(), - tasks: Type.Array( - Type.Object({ - content: Type.String(), - details: Type.Optional(Type.String()), + parameters: z.object({ + phases: z.array( + z.object({ + name: z.string(), + tasks: z.array( + z.object({ + content: z.string(), + details: z.string().optional(), }), ), }), @@ -686,9 +686,9 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t-defaulted-null", description: "", - parameters: Type.Object({ - note: Type.Union([Type.String(), Type.Null()]), - tags: Type.Array(Type.String(), { default: [] }), + parameters: z.object({ + note: z.union([z.string(), z.null()]), + tags: z.array(z.string()).default([]), }), }; @@ -707,8 +707,8 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t-defaulted-isolation", description: "", - parameters: Type.Object({ - tags: Type.Array(Type.String(), { default: [] }), + parameters: z.object({ + tags: z.array(z.string()).default([]), }), }; @@ -734,10 +734,10 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t-optional-nulls", description: "", - parameters: Type.Object({ - path: Type.String(), - offset: Type.Optional(Type.Number()), - limit: Type.Optional(Type.Number()), + parameters: z.object({ + path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), }), }; @@ -756,9 +756,9 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t-root-json-null", description: "", - parameters: Type.Object({ - note: Type.Union([Type.String(), Type.Null()]), - tags: Type.Array(Type.String(), { default: [] }), + parameters: z.object({ + note: z.union([z.string(), z.null()]), + tags: z.array(z.string()).default([]), }), }; @@ -780,10 +780,10 @@ describe("Tool argument coercion", () => { const tool: Tool = { name: "t-nested-json", description: "", - parameters: Type.Object({ - payload: Type.Object({ - flags: Type.Array(Type.Boolean()), - meta: Type.Object({ count: Type.Number() }), + parameters: z.object({ + payload: z.object({ + flags: z.array(z.boolean()), + meta: z.object({ count: z.number() }), }), }), }; diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 133ecbb42..417730f55 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 { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Tool } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 = Type.Object({ - expression: Type.String({ description: "The mathematical expression to evaluate" }), +const calculateSchema = z.object({ + expression: z.string().describe("The mathematical expression to evaluate"), }); const calculateTool: Tool = { diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 110ea6f06..2dafcd876 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 { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist -const emptySchema = Type.Object({}); +const emptySchema = z.object({}); // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1d70022c3..480292840 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,18 +1,32 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed the extension and hook runtime API by moving schema typing from direct TypeBox imports to `TSchema` from `@oh-my-pi/pi-ai`, requiring callers who use TypeScript imports of `Type` to migrate via provided injected modules ### Added +- Added `zod` to the Extension, Custom Tool, Hook, and Custom Command APIs as `pi.zod` so extension and plugin authors can define tool schemas with Zod without separate imports +- Added `pi.zod` as a canonical schema API for examples and extension plugins while keeping `typebox` available as legacy compatibility - Added a `telemetry` option to `createAgentSession` for passing OpenTelemetry configuration through to the underlying Agent ### Changed +- Changed tool parameter schemas across the agent to use the shared Pi schema pipeline (`TSchema` and `fromTypeBox`) instead of direct AJV/TypeBox compilation for stricter schema validation compatibility +- Changed GitHub tool input schema shape to expose operation fields in a flat schema form without legacy `run_watch`-style nesting - Changed Python session pooling to remove the previous 4-session retention cap and 5-minute idle-session eviction, so kernels now stay alive for a session until explicitly disposed via `disposeKernelSessionsByOwner` or `disposeAllKernelSessions` - Changed kernel cleanup behavior to avoid automatic eviction by idle timeout and capacity pressure, so additional Python sessions are not queued behind retained-session shutdown retries +- Replaced the bundled `@sinclair/typebox` runtime dependency with an in-repo Zod-backed shim exposed through `pi.typebox.Type.*`. Common builders (`Object`, `String`, `Number`, `Integer`, `Boolean`, `Array`, `Tuple`, `Union`, `Intersect`, `Literal`, `Enum`, `Optional`, `Nullable`, `Record`, `Partial`, `Required`, `Pick`, `Omit`, `Composite`, …) keep their existing call signatures but now return Zod schemas that flow through the same validation/wire pipeline as `pi.zod`. Plugins that imported `@sinclair/typebox` directly (rather than through `pi.typebox`) or relied on TypeBox-only APIs (`TypeCompiler`, `TypeRegistry`, the `Symbol(TypeBox.Kind)` marker) must now vendor `@sinclair/typebox` in their own package. + +### Deprecated + +- Deprecated direct TypeBox-only examples for plugin schemas by updating example documentation to prefer `pi.zod` ### Fixed +- Fixed subagent execution hangs by enforcing `task.maxRuntimeMs` as a wall-clock limit even when inference streaming stalls, so stuck subagents now abort and report runtime-limit exceeded +- Fixed tool schema compatibility validation by routing TypeBox schemas through shared conversion and Zod-based validation to avoid strict-schema provider mismatches - Fixed Python execution cancellation and timeouts by escalating to kernel shutdown if `SIGINT` did not terminate a running cell within 2 seconds, preventing indefinite hangs in queued or stuck sessions - Fixed cleanup blocking during long-running executions by forcing a kernel shutdown path when interrupt-based cancellation is ignored - Fixed bash output emitting a spurious `[… 0 lines elided (NB) …]` marker (and reordering the artifact link before the command output) when the shell minimizer rewrote a small command's output. After `OutputSink.replace()` swapped the minimized text into the buffer, the subsequent `sink.push("[raw output: artifact://N]\n")` chunk was funneled back into the (now empty) head-retention window while the pre-replace `#totalBytes` still tracked the original raw stream — so `dump()` composed ` + + ` instead of ` + `. `replace()` now realigns `#totalBytes`/`#totalLines`/`#sawData`/`#truncated` to the authoritative buffer and disables head retention for the lifetime of the sink, so further pushes append to the tail buffer in order. The bash executor also drops the leading `\n` on the artifact-link push when the minimized text already ends with one so the separator stays single-newline. diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md index c929defcf..0c88fb304 100644 --- a/packages/coding-agent/examples/custom-tools/README.md +++ b/packages/coding-agent/examples/custom-tools/README.md @@ -47,7 +47,6 @@ See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation. **Factory pattern:** ```typescript -import { Type } from "@sinclair/typebox"; import { StringEnum } from "@oh-my-pi/pi-ai"; import { Text } from "@oh-my-pi/pi-tui"; import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent"; @@ -56,7 +55,7 @@ const factory: CustomToolFactory = (pi) => ({ name: "my_tool", label: "My Tool", description: "Tool description for LLM", - parameters: Type.Object({ + parameters: pi.zod.object({ action: StringEnum(["list", "add"] as const), }), @@ -78,6 +77,8 @@ const factory: CustomToolFactory = (pi) => ({ export default factory; ``` +**Legacy:** `parameters: pi.typebox.Type.Object({ ... })` still works; the injected `typebox` is a small Zod-backed shim, and schemas flow through the same Zod pipeline as `pi.zod` schemas. + **Custom rendering:** ```typescript @@ -96,14 +97,17 @@ renderResult(result, { expanded, isPartial }, theme) { }, ``` -**Use StringEnum for string parameters** (required for Google API compatibility): +**Use `StringEnum` for discriminated string tool args** (required for Google API compatibility): ```typescript import { StringEnum } from "@oh-my-pi/pi-ai"; -// Good -action: StringEnum(["list", "add"] as const); +const { z } = pi.zod; -// Bad - doesn't work with Google -action: Type.Union([Type.Literal("list"), Type.Literal("add")]); +// Good — Google-safe enum wiring +parameters: z.object({ + action: StringEnum(["list", "add"] as const), +}); + +// Avoid raw union-of-literals patterns that don't degrade well for strict JSON Schema providers ``` diff --git a/packages/coding-agent/examples/custom-tools/hello/index.ts b/packages/coding-agent/examples/custom-tools/hello/index.ts index 28af32971..1744dff4a 100644 --- a/packages/coding-agent/examples/custom-tools/hello/index.ts +++ b/packages/coding-agent/examples/custom-tools/hello/index.ts @@ -4,8 +4,8 @@ const factory: CustomToolFactory = pi => ({ name: "hello", label: "Hello", description: "A simple greeting tool", - parameters: pi.typebox.Type.Object({ - name: pi.typebox.Type.String({ description: "Name to greet" }), + parameters: pi.zod.object({ + name: pi.zod.string().describe("Name to greet"), }), async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 05fea139b..747d33c9d 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -71,9 +71,10 @@ See [docs/extensions.md](../../docs/extensions.md) for full documentation. ```typescript import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; export default function (pi: ExtensionAPI) { + const z = pi.zod; + // Subscribe to lifecycle events pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { @@ -87,8 +88,8 @@ export default function (pi: ExtensionAPI) { name: "greet", label: "Greeting", description: "Generate a greeting", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), + parameters: z.object({ + name: z.string().describe("Name to greet"), }), async execute(toolCallId, params, onUpdate, ctx, signal) { return { @@ -108,18 +109,28 @@ export default function (pi: ExtensionAPI) { } ``` +**Legacy TypeBox-style schemas** (`pi.typebox`) remain available for older extensions and are backed by a tiny Zod-shim — prefer `pi.zod` directly for new code. + +```typescript +const { Type } = pi.typebox; +parameters: Type.Object({ name: Type.String() }); +``` + ## Key Patterns -**Use StringEnum for string parameters** (required for Google API compatibility): +**Use `StringEnum` for discriminated string tool args** (required for Google API compatibility): ```typescript import { StringEnum } from "@oh-my-pi/pi-ai"; -// Good -action: StringEnum(["list", "add"] as const); +const { z } = pi.zod; -// Bad - doesn't work with Google -action: Type.Union([Type.Literal("list"), Type.Literal("add")]); +// Good — Google-safe enum wiring +parameters: z.object({ + action: StringEnum(["list", "add"] as const), +}); + +// Avoid raw union-of-literals patterns that don't degrade well for strict JSON Schema providers ``` **State persistence via details:** diff --git a/packages/coding-agent/examples/extensions/api-demo.ts b/packages/coding-agent/examples/extensions/api-demo.ts index b57c87afb..9883e6c59 100644 --- a/packages/coding-agent/examples/extensions/api-demo.ts +++ b/packages/coding-agent/examples/extensions/api-demo.ts @@ -1,39 +1,35 @@ /** * API Demo Extension * - * Demonstrates using ExtensionAPI's logger, typebox, and pi module access. + * Demonstrates using ExtensionAPI's logger, injected `pi.zod`, and pi module access. * These features are now exposed directly on the ExtensionAPI, matching * the CustomToolAPI interface. */ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function (pi: ExtensionAPI) { - // 1. Access TypeBox directly from pi.typebox (no separate import needed) - const { Type } = pi.typebox; + const { z } = pi.zod; - // 2. Access the logger for debugging - pi.logger.debug("API demo extension loaded"); - - // 3. Register a tool that uses all three API features - // Import StringEnum from typebox helpers + // Access shared schema helpers from package exports (e.g. StringEnum for Google-safe enums) const { StringEnum } = pi.pi; + // Access the logger for debugging + pi.logger.debug("API demo extension loaded"); + pi.registerTool({ name: "api_demo", label: "API Demo", - description: "Demonstrates ExtensionAPI capabilities: logger, typebox, and pi module access", - parameters: Type.Object({ - message: Type.String({ description: "Test message" }), - logLevel: Type.Optional( - StringEnum(["error", "warn", "debug"], { - description: "Log level to use", - default: "debug", - }), - ), + description: "Demonstrates ExtensionAPI capabilities: logger, zod, and pi module access", + parameters: z.object({ + message: z.string().describe("Test message"), + logLevel: StringEnum(["error", "warn", "debug"], { + description: "Log level to use", + default: "debug", + }), }), async execute(_toolCallId, params, _onUpdate, ctx, _signal) { - const { message, logLevel = "debug" } = params as { message: string; logLevel?: "error" | "warn" | "debug" }; + const { message, logLevel } = params; // Use logger at specified level pi.logger[logLevel]("API demo tool executed", { message, logLevel }); @@ -58,7 +54,7 @@ export default function (pi: ExtensionAPI) { ``, `Features demonstrated:`, `1. ✓ Logger access via pi.logger`, - `2. ✓ TypeBox access via pi.typebox`, + `2. ✓ Zod access via pi.zod`, `3. ✓ Pi module access via pi.pi`, ``, `Context:`, diff --git a/packages/coding-agent/examples/extensions/hello.ts b/packages/coding-agent/examples/extensions/hello.ts index fe910bce0..7f0362280 100644 --- a/packages/coding-agent/examples/extensions/hello.ts +++ b/packages/coding-agent/examples/extensions/hello.ts @@ -1,24 +1,23 @@ /** * Hello Tool - Minimal custom tool example * - * Demonstrates using ExtensionAPI's logger, typebox, and pi module access. + * Demonstrates using ExtensionAPI's logger, injected `pi.zod`, and pi module access. */ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function (pi: ExtensionAPI) { - // Access TypeBox via pi.typebox (no need to import separately) - const { Type } = pi.typebox; + const { z } = pi.zod; pi.registerTool({ name: "hello", label: "Hello", description: "A simple greeting tool", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), + parameters: z.object({ + name: z.string().describe("Name to greet"), }), async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { - const { name } = params as { name: string }; + const { name } = params; // Use logger for debugging pi.logger.debug("Hello tool executed", { name }); diff --git a/packages/coding-agent/examples/extensions/reload-runtime.ts b/packages/coding-agent/examples/extensions/reload-runtime.ts index e7b41e765..e9f777fb0 100644 --- a/packages/coding-agent/examples/extensions/reload-runtime.ts +++ b/packages/coding-agent/examples/extensions/reload-runtime.ts @@ -5,10 +5,11 @@ * tool that queues a follow-up command to trigger reload. */ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function (pi: ExtensionAPI) { + const { z } = pi.zod; + // Command entrypoint for reload. // Treat reload as terminal for this handler. pi.registerCommand("reload-runtime", { @@ -25,7 +26,7 @@ export default function (pi: ExtensionAPI) { name: "reload_runtime", label: "Reload Runtime", description: "Reload extensions, skills, prompts, and themes", - parameters: Type.Object({}), + parameters: z.object({}), async execute() { pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); return { diff --git a/packages/coding-agent/examples/extensions/with-deps/index.ts b/packages/coding-agent/examples/extensions/with-deps/index.ts index 7143f5ccb..623ed9e15 100644 --- a/packages/coding-agent/examples/extensions/with-deps/index.ts +++ b/packages/coding-agent/examples/extensions/with-deps/index.ts @@ -5,17 +5,18 @@ * Requires: npm install in this directory */ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; import ms from "ms"; export default function (pi: ExtensionAPI) { + const { z } = pi.zod; + // Register a tool that uses ms pi.registerTool({ name: "parse_duration", label: "Parse Duration", description: "Parse a human-readable duration string (e.g., '2 days', '1h', '5m') to milliseconds", - parameters: Type.Object({ - duration: Type.String({ description: "Duration string like '2 days', '1h', '5m'" }), + parameters: z.object({ + duration: z.string().describe("Duration string like '2 days', '1h', '5m'"), }), execute: async (_toolCallId, params) => { const result = ms(params.duration as ms.StringValue); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts index bd7bf279d..e4ffb848b 100644 --- a/packages/coding-agent/examples/sdk/06-extensions.ts +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -41,6 +41,8 @@ console.log(); import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; export default function (pi: ExtensionAPI) { + const { z } = pi.zod; + pi.on("agent_start", async () => { console.log("[Extension] Agent starting"); }); @@ -60,8 +62,8 @@ export default function (pi: ExtensionAPI) { name: "my_tool", label: "My Tool", description: "Does something useful", - parameters: Type.Object({ - input: Type.String(), + parameters: z.object({ + input: z.string(), }), execute: async (_toolCallId, params, _onUpdate, _ctx, _signal) => ({ content: [{ type: "text", text: \`Processed: \${params.input}\` }], diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 20fed5277..9e703ac20 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -54,10 +54,8 @@ "@oh-my-pi/pi-tui": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@puppeteer/browsers": "catalog:", - "@sinclair/typebox": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", - "ajv": "catalog:", "chalk": "catalog:", "diff": "catalog:", "fflate": "catalog:", diff --git a/packages/coding-agent/src/autoresearch/tools/init-experiment.ts b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts index 9e09dcbe6..c1db6854c 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 { StringEnum } from "@oh-my-pi/pi-ai"; + import { Text } from "@oh-my-pi/pi-tui"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; @@ -16,46 +16,43 @@ export const HARNESS_FILENAME = "autoresearch.sh"; export const DEFAULT_HARNESS_COMMAND = `bash ${HARNESS_FILENAME}`; const HARNESS_COMMIT_TITLE = "autoresearch: harness setup"; -const initExperimentSchema = Type.Object({ - name: Type.String({ description: "Human-readable experiment name." }), - goal: Type.Optional(Type.String({ description: "Free-form description of what this session optimizes." })), - primary_metric: Type.String({ - description: +const initExperimentSchema = z.object({ + name: z.string().describe("Human-readable experiment name."), + goal: z.string().describe("Free-form description of what this session optimizes.").optional(), + primary_metric: z + .string() + .describe( "Primary metric name shown in the dashboard. Match the `METRIC =` lines printed by the benchmark.", - }), - metric_unit: Type.Optional( - Type.String({ description: "Unit for the primary metric (e.g. ms, µs, mb). Empty when unitless." }), - ), - direction: Type.Optional( - StringEnum(["lower", "higher"], { description: "Whether lower or higher values are better. Defaults to lower." }), - ), - secondary_metrics: Type.Optional( - Type.Array(Type.String(), { - description: "Names of secondary metrics tracked alongside the primary metric.", - }), - ), - scope_paths: Type.Optional( - Type.Array(Type.String(), { - description: - "Files or directories the agent expects to modify. Used post-hoc to flag scope deviations on log_experiment; never used to block edits.", - }), - ), - off_limits: Type.Optional( - Type.Array(Type.String(), { - description: - "Paths the agent SHOULD NOT modify. Used post-hoc to flag scope deviations on log_experiment; never used to block edits.", - }), - ), - constraints: Type.Optional( - Type.Array(Type.String(), { description: "Free-form constraints (e.g. 'no api break')." }), - ), - max_iterations: Type.Optional(Type.Number({ description: "Soft cap on iterations per segment. Optional." })), - new_segment: Type.Optional( - Type.Boolean({ - description: - "When true, bump to a new segment even when an active session exists. New baselines and best-metric reset.", - }), - ), + ), + metric_unit: z.string().describe("Unit for the primary metric (e.g. ms, µs, mb). Empty when unitless.").optional(), + direction: z + .enum(["lower", "higher"] as const) + .describe("Whether lower or higher values are better. Defaults to lower.") + .optional(), + secondary_metrics: z + .array(z.string()) + .describe("Names of secondary metrics tracked alongside the primary metric.") + .optional(), + scope_paths: z + .array(z.string()) + .describe( + "Files or directories the agent expects to modify. Used post-hoc to flag scope deviations on log_experiment; never used to block edits.", + ) + .optional(), + off_limits: z + .array(z.string()) + .describe( + "Paths the agent SHOULD NOT modify. Used post-hoc to flag scope deviations on log_experiment; never used to block edits.", + ) + .optional(), + constraints: z.array(z.string()).describe("Free-form constraints (e.g. 'no api break').").optional(), + max_iterations: z.number().describe("Soft cap on iterations per segment. Optional.").optional(), + new_segment: z + .boolean() + .describe( + "When true, bump to a new segment even when an active session exists. New baselines and best-metric reset.", + ) + .optional(), }); 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 21989436e..bb6ea635b 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -1,8 +1,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { StringEnum } from "@oh-my-pi/pi-ai"; + import { Text } from "@oh-my-pi/pi-tui"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; @@ -36,46 +36,37 @@ import type { const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment", "update_notes"]; -const logExperimentSchema = Type.Object({ - metric: Type.Number({ - description: "Primary metric value for this run. May differ from the parsed value; deviation is recorded.", - }), - status: StringEnum(["keep", "discard", "crash", "checks_failed"], { - description: "Outcome for this run.", - }), - description: Type.String({ description: "Short description of the experiment." }), - metrics: Type.Optional( - Type.Record(Type.String(), Type.Number(), { description: "Secondary metrics for this run." }), - ), - asi: Type.Optional( - Type.Object( - {}, - { - additionalProperties: Type.Unknown(), - description: "Free-form structured metadata captured for this run (hypothesis, learnings, etc.).", - }, - ), - ), - commit: Type.Optional( - Type.String({ description: "Override the commit hash recorded for this run. Defaults to the current HEAD." }), - ), - justification: Type.Optional( - Type.String({ - description: - "Required when the run modifies paths outside scope or inside off-limits and you still want it kept. Free-form explanation.", - }), - ), - flag_runs: Type.Optional( - Type.Array( - Type.Object({ - run_id: Type.Number({ description: "Run id (#) of a previously logged run to flag as suspect." }), - reason: Type.String({ - description: "Why this earlier run is suspect (e.g. reward-hacked, broken metric).", - }), +const logExperimentSchema = z.object({ + metric: z + .number() + .describe("Primary metric value for this run. May differ from the parsed value; deviation is recorded."), + status: z.enum(["keep", "discard", "crash", "checks_failed"] as const).describe("Outcome for this run."), + description: z.string().describe("Short description of the experiment."), + metrics: z.record(z.string(), z.number()).describe("Secondary metrics for this run.").optional(), + asi: z + .object({}) + .passthrough() + .describe("Free-form structured metadata captured for this run (hypothesis, learnings, etc.).") + .optional(), + commit: z + .string() + .describe("Override the commit hash recorded for this run. Defaults to the current HEAD.") + .optional(), + justification: z + .string() + .describe( + "Required when the run modifies paths outside scope or inside off-limits and you still want it kept. Free-form explanation.", + ) + .optional(), + flag_runs: z + .array( + z.object({ + run_id: z.number().describe("Run id (#) of a previously logged run to flag as suspect."), + reason: z.string().describe("Why this earlier run is suspect (e.g. reward-hacked, broken metric)."), }), - { description: "Mark earlier runs as flagged. Flagged runs are excluded from baseline and best-metric math." }, - ), - ), + ) + .describe("Mark earlier runs as flagged. Flagged runs are excluded from baseline and best-metric math.") + .optional(), }); 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 df788bcdf..59d06d69e 100644 --- a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts @@ -3,7 +3,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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output"; @@ -26,8 +26,8 @@ import { openAutoresearchStorageIfExists } from "../storage"; import type { AutoresearchToolFactoryOptions, RunDetails, RunExperimentProgressDetails } from "../types"; import { DEFAULT_HARNESS_COMMAND } from "./init-experiment"; -const runExperimentSchema = Type.Object({ - timeout_seconds: Type.Optional(Type.Number({ description: "Timeout in seconds. Defaults to 600." })), +const runExperimentSchema = z.object({ + timeout_seconds: z.number().describe("Timeout in seconds. Defaults to 600.").optional(), }); 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 bf42dd9a9..d5b378a96 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; @@ -8,16 +8,16 @@ import { buildExperimentState } from "../state"; import { openAutoresearchStorageIfExists } from "../storage"; import type { AutoresearchToolFactoryOptions } from "../types"; -const updateNotesSchema = Type.Object({ - body: Type.String({ - description: "Replacement markdown body for the active autoresearch session's notes (your durable playbook).", - }), - append_idea: Type.Optional( - Type.String({ - description: - "When set, append this string as a new bullet under an Ideas section instead of replacing the body. `body` is ignored.", - }), - ), +const updateNotesSchema = z.object({ + body: z + .string() + .describe("Replacement markdown body for the active autoresearch session's notes (your durable playbook)."), + append_idea: z + .string() + .describe( + "When set, append this string as a new bullet under an Ideas section instead of replacing the body. `body` is ignored.", + ) + .optional(), }); 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 2ee10abe1..38c0055b0 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 = Type.Object({ - files: Type.Array(Type.String({ description: "File path" }), { minItems: 1 }), - goal: Type.Optional(Type.String({ description: "Optional analysis focus" })), +const analyzeFileSchema = z.object({ + files: z.array(z.string().describe("File path")).min(1), + goal: z.string().describe("Optional analysis focus").optional(), }); 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 953fb2869..345413e70 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 = Type.Object({ - files: Type.Array(Type.String({ description: "Files to diff" }), { minItems: 1, maxItems: 10 }), - staged: Type.Optional(Type.Boolean({ description: "Use staged changes (default: true)" })), +const gitFileDiffSchema = z.object({ + files: z.array(z.string().describe("Files to diff")).min(1).max(10), + staged: z.boolean().describe("Use staged changes (default: true)").optional(), }); 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 a39191cb9..37cd1272b 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,12 @@ -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { DiffHunk, FileHunks } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; -const gitHunkSchema = Type.Object({ - file: Type.String({ description: "File path" }), - hunks: Type.Optional(Type.Array(Type.Number({ description: "1-based hunk indices" }), { minItems: 1 })), - staged: Type.Optional(Type.Boolean({ description: "Use staged changes (default: true)" })), +const gitHunkSchema = z.object({ + file: z.string().describe("File path"), + hunks: z.array(z.number().describe("1-based hunk indices")).min(1).optional(), + staged: z.boolean().describe("Use staged changes (default: true)").optional(), }); 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 bd493e2c7..3e66f52c1 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 = Type.Object({ - staged: Type.Optional(Type.Boolean({ description: "Use staged changes (default: true)" })), - include_untracked: Type.Optional(Type.Boolean({ description: "Include untracked files when staged=false" })), +const gitOverviewSchema = z.object({ + staged: z.boolean().describe("Use staged changes (default: true)").optional(), + include_untracked: z.boolean().describe("Include untracked files when staged=false").optional(), }); 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 6ccf0e929..8d28fff6b 100644 --- a/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts +++ b/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts @@ -1,29 +1,29 @@ -import { type TSchema, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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>( +const changelogEntryProperties = CHANGELOG_CATEGORIES.reduce>( (acc, category) => { - acc[category] = Type.Optional(Type.Array(Type.String())); + acc[category] = z.array(z.string()).optional(); return acc; }, - {} as Record, + {} as Record, ); -const changelogEntriesSchema = Type.Object(changelogEntryProperties); -const changelogDeletionsSchema = Type.Object(changelogEntryProperties, { - description: "Entries to remove from existing changelog sections (case-insensitive match)", -}); +const changelogEntriesSchema = z.object(changelogEntryProperties); +const changelogDeletionsSchema = z + .object(changelogEntryProperties) + .describe("Entries to remove from existing changelog sections (case-insensitive match)"); -const changelogEntrySchema = Type.Object({ - path: Type.String(), +const changelogEntrySchema = z.object({ + path: z.string(), entries: changelogEntriesSchema, - deletions: Type.Optional(changelogDeletionsSchema), + deletions: changelogDeletionsSchema.optional(), }); -const proposeChangelogSchema = Type.Object({ - entries: Type.Array(changelogEntrySchema), +const proposeChangelogSchema = z.object({ + entries: z.array(changelogEntrySchema), }); 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 081c6f096..8f13b3f16 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 = Type.Object({ +const proposeCommitSchema = z.object({ type: commitTypeSchema, - scope: Type.Union([Type.String(), Type.Null()]), - summary: Type.String(), - details: Type.Array(detailSchema), - issue_refs: Type.Array(Type.String()), + scope: z.union([z.string(), z.null()]), + summary: z.string(), + details: z.array(detailSchema), + issue_refs: z.array(z.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 8b83647eb..1cde4ef8c 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; -const recentCommitsSchema = Type.Object({ - count: Type.Optional(Type.Number({ description: "Number of commits to fetch", minimum: 1, maximum: 50 })), +const recentCommitsSchema = z.object({ + count: z.number().min(1).max(50).describe("Number of commits to fetch").optional(), }); 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 3c912aa41..6f0c49eaf 100644 --- a/packages/coding-agent/src/commit/agentic/tools/schemas.ts +++ b/packages/coding-agent/src/commit/agentic/tools/schemas.ts @@ -1,31 +1,31 @@ -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; -export const commitTypeSchema = Type.Union([ - Type.Literal("feat"), - Type.Literal("fix"), - Type.Literal("refactor"), - Type.Literal("perf"), - Type.Literal("docs"), - Type.Literal("test"), - Type.Literal("build"), - Type.Literal("ci"), - Type.Literal("chore"), - Type.Literal("style"), - Type.Literal("revert"), -]); +export const commitTypeSchema = z.enum([ + "feat", + "fix", + "refactor", + "perf", + "docs", + "test", + "build", + "ci", + "chore", + "style", + "revert", +] as const); -export const detailSchema = Type.Object({ - text: Type.String(), - changelog_category: Type.Optional( - Type.Union([ - Type.Literal("Added"), - Type.Literal("Changed"), - Type.Literal("Fixed"), - Type.Literal("Deprecated"), - Type.Literal("Removed"), - Type.Literal("Security"), - Type.Literal("Breaking Changes"), - ]), - ), - user_visible: Type.Optional(Type.Boolean()), +export const detailSchema = z.object({ + text: z.string(), + changelog_category: z + .union([ + z.literal("Added"), + z.literal("Changed"), + z.literal("Fixed"), + z.literal("Deprecated"), + z.literal("Removed"), + z.literal("Security"), + z.literal("Breaking Changes"), + ]) + .optional(), + user_visible: z.boolean().optional(), }); 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 975eb0824..ccb23aba7 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { CommitAgentState, SplitCommitGroup, SplitCommitPlan } from "../../../commit/agentic/state"; import { computeDependencyOrder } from "../../../commit/agentic/topo-sort"; import { @@ -15,31 +15,32 @@ import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; import { commitTypeSchema, detailSchema } from "./schemas.js"; -const hunkSelectorSchema = Type.Union([ - Type.Object({ type: Type.Literal("all") }), - Type.Object({ type: Type.Literal("indices"), indices: Type.Array(Type.Number(), { minItems: 1 }) }), - Type.Object({ type: Type.Literal("lines"), start: Type.Number(), end: Type.Number() }), +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 fileChangeSchema = Type.Object({ - path: Type.String(), +const fileChangeSchema = z.object({ + path: z.string(), hunks: hunkSelectorSchema, }); -const splitCommitSchema = Type.Object({ - commits: Type.Array( - Type.Object({ - changes: Type.Array(fileChangeSchema, { minItems: 1 }), - type: commitTypeSchema, - scope: Type.Union([Type.String(), Type.Null()]), - summary: Type.String(), - details: Type.Optional(Type.Array(detailSchema)), - issue_refs: Type.Optional(Type.Array(Type.String())), - rationale: Type.Optional(Type.String()), - dependencies: Type.Optional(Type.Array(Type.Number())), - }), - { minItems: 2 }, - ), +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), }); interface SplitCommitResponse { diff --git a/packages/coding-agent/src/commit/analysis/summary.ts b/packages/coding-agent/src/commit/analysis/summary.ts index 726c01a42..5dd23ff1f 100644 --- a/packages/coding-agent/src/commit/analysis/summary.ts +++ b/packages/coding-agent/src/commit/analysis/summary.ts @@ -2,7 +2,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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"; @@ -12,8 +12,8 @@ import { extractTextContent, extractToolCall } from "../utils"; const SummaryTool = { name: "create_commit_summary", description: "Generate the summary line for a conventional commit message.", - parameters: Type.Object({ - summary: Type.String(), + parameters: z.object({ + summary: z.string(), }), }; @@ -83,7 +83,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 { summary: string }; + const parsed = validateToolCall([SummaryTool], toolCall) as z.infer<(typeof SummaryTool)["parameters"]>; 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 0d86a683c..bc43cd1e7 100644 --- a/packages/coding-agent/src/commit/changelog/generate.ts +++ b/packages/coding-agent/src/commit/changelog/generate.ts @@ -2,27 +2,23 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, 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 { type TSchema, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 { toReasoningEffort } from "../../thinking"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../utils"; -const changelogEntryProperties = CHANGELOG_CATEGORIES.reduce>( - (acc, category) => { - acc[category] = Type.Optional(Type.Array(Type.String())); - return acc; - }, - {} as Record, -); +const changelogEntryShape = Object.fromEntries( + CHANGELOG_CATEGORIES.map(c => [c, z.array(z.string()).optional()] as const), +) as Record>>; -const changelogEntriesSchema = Type.Object(changelogEntryProperties); +const changelogEntriesSchema = z.object(changelogEntryShape); export const changelogTool = { name: "create_changelog_entries", description: "Generate changelog entries grouped by Keep a Changelog categories.", - parameters: Type.Object({ + parameters: z.object({ entries: changelogEntriesSchema, }), }; @@ -72,7 +68,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 ChangelogGenerationResult; + const parsed = validateToolCall([changelogTool], toolCall) as z.infer<(typeof changelogTool)["parameters"]>; 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 13bf88869..8d35955e4 100644 --- a/packages/coding-agent/src/commit/shared-llm.ts +++ b/packages/coding-agent/src/commit/shared-llm.ts @@ -1,49 +1,39 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { validateToolCall } from "@oh-my-pi/pi-ai"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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", +]); + /** - * Shared TypeBox schema for the `create_conventional_analysis` tool used by + * Shared Zod 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 = Type.Object({ - type: Type.Union([ - Type.Literal("feat"), - Type.Literal("fix"), - Type.Literal("refactor"), - Type.Literal("docs"), - Type.Literal("test"), - Type.Literal("chore"), - Type.Literal("style"), - Type.Literal("perf"), - Type.Literal("build"), - Type.Literal("ci"), - Type.Literal("revert"), - ]), - scope: Type.Union([Type.String(), Type.Null()]), - details: Type.Array( - Type.Object({ - text: Type.String(), - changelog_category: Type.Optional( - Type.Union([ - Type.Literal("Added"), - Type.Literal("Changed"), - Type.Literal("Fixed"), - Type.Literal("Deprecated"), - Type.Literal("Removed"), - Type.Literal("Security"), - Type.Literal("Breaking Changes"), - ]), - ), - user_visible: Type.Optional(Type.Boolean()), - }), - ), - issue_refs: Type.Array(Type.String()), -}); +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()), + }) + .strict(); export interface ConventionalAnalysisTool { name: "create_conventional_analysis"; @@ -80,7 +70,7 @@ export function parseConventionalAnalysisResponse( ): ConventionalAnalysis { const toolCall = extractToolCall(message, tool.name); if (toolCall) { - const parsed = validateToolCall([tool], toolCall) as ParsedConventionalAnalysis; + const parsed = validateToolCall([tool], toolCall) as z.infer; 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 d9b6ba411..76bd93955 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -1,10 +1,14 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; -import { Value } from "@sinclair/typebox/value"; -import type { ErrorObject } from "ajv"; 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 { + instancePath: string; + message: string | undefined; +} function migrateJsonToYml(jsonPath: string, ymlPath: string) { try { @@ -25,7 +29,7 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) { export interface IConfigFile { readonly id: string; - readonly schema: TSchema; + readonly schema: ZodType; path?(): string; load(): T | null; invalidate?(): void; @@ -35,7 +39,7 @@ export class ConfigError extends Error { readonly #message: string; constructor( public readonly id: string, - public readonly schemaErrors: ErrorObject[] | null | undefined, + public readonly schemaErrors: ConfigSchemaError[] | null | undefined, public readonly other?: { err: unknown; stage: string }, ) { let messages: string[] | undefined; @@ -68,7 +72,6 @@ export class ConfigError extends Error { break; default: message = `${title}\n${messages!.map(m => ` - ${m}`).join("\n")}`; - break; } super(message, { cause }); @@ -99,7 +102,7 @@ export class ConfigFile implements IConfigFile { constructor( readonly id: string, - readonly schema: TSchema, + readonly schema: ZodType, configPath: string = path.join(getAgentDir(), `${id}.yml`), ) { this.#basePath = configPath; @@ -146,7 +149,14 @@ export class ConfigFile implements IConfigFile { } createDefault(): T { - return Value.Default(this.schema, [], undefined) as T; + const parsed = this.schema.safeParse({}); + if (parsed.success) return parsed.data; + const fallback = this.schema.safeParse(undefined); + if (fallback.success) return fallback.data; + throw new ConfigError(this.id, undefined, { + err: new Error("Schema produced no default value"), + stage: "createDefault", + }); } #storeCache(result: LoadResult): LoadResult { @@ -169,17 +179,29 @@ export class ConfigFile implements IConfigFile { throw new Error(`Invalid config file path: ${this.#basePath}`); } - if (!Value.Check(this.schema, parsed)) { - const schemaErrors: ErrorObject[] = []; - for (const err of Value.Errors(this.schema, parsed)) { - schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject); + const checked = this.schema.safeParse(parsed); + if (!checked.success) { + 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; } const error = new ConfigError(this.id, schemaErrors); logger.warn("Failed to parse config file", { path: this.path(), error }); return this.#storeCache({ error, status: "error" }); } - return this.#storeCache({ value: parsed as T, status: "ok" }); + const value = checked.data; + try { + this.#auxValidate?.(value); + } catch (error) { + const wrapped = + error instanceof ConfigError + ? error + : new ConfigError(this.id, undefined, { err: error, stage: "AuxValidate" }); + return this.#storeCache({ error: wrapped, status: "error" }); + } + return this.#storeCache({ value, status: "ok" }); } catch (error) { if (isEnoent(error)) { return this.#storeCache({ status: "not-found" }); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 2c2cffc0a..e7d1710cc 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -30,7 +30,6 @@ const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; import { isRecord, logger } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme"; import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; @@ -43,6 +42,13 @@ import { formatCanonicalVariantSelector, type ModelEquivalenceConfig, } from "./model-equivalence"; +import { + type ModelOverride, + type ModelsConfig, + ModelsConfigSchema, + type ProviderAuthMode, + type ProviderDiscovery, +} from "./models-config-schema"; import { type Settings, settings } from "./settings"; export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig }; @@ -121,194 +127,6 @@ export function getRoleInfo(role: string, settings: Settings): RoleInfo { return { name: role, color: "muted" }; } -const OpenRouterRoutingSchema = Type.Object({ - only: Type.Optional(Type.Array(Type.String())), - order: Type.Optional(Type.Array(Type.String())), -}); - -// Schema for Vercel AI Gateway routing preferences -const VercelGatewayRoutingSchema = Type.Object({ - only: Type.Optional(Type.Array(Type.String())), - order: Type.Optional(Type.Array(Type.String())), -}); - -// Schema for OpenAI compatibility settings -const ReasoningEffortMapSchema = Type.Object({ - minimal: Type.Optional(Type.String()), - low: Type.Optional(Type.String()), - medium: Type.Optional(Type.String()), - high: Type.Optional(Type.String()), - xhigh: Type.Optional(Type.String()), -}); - -const OpenAICompatSchema = Type.Object({ - supportsStore: Type.Optional(Type.Boolean()), - supportsDeveloperRole: Type.Optional(Type.Boolean()), - supportsReasoningEffort: Type.Optional(Type.Boolean()), - reasoningEffortMap: Type.Optional(ReasoningEffortMapSchema), - maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), - supportsUsageInStreaming: Type.Optional(Type.Boolean()), - requiresToolResultName: Type.Optional(Type.Boolean()), - requiresMistralToolIds: Type.Optional(Type.Boolean()), - requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), - requiresThinkingAsText: Type.Optional(Type.Boolean()), - reasoningContentField: Type.Optional( - Type.Union([Type.Literal("reasoning_content"), Type.Literal("reasoning"), Type.Literal("reasoning_text")]), - ), - requiresReasoningContentForToolCalls: Type.Optional(Type.Boolean()), - requiresAssistantContentForToolCalls: Type.Optional(Type.Boolean()), - supportsToolChoice: Type.Optional(Type.Boolean()), - disableReasoningOnForcedToolChoice: Type.Optional(Type.Boolean()), - thinkingFormat: Type.Optional( - Type.Union([ - Type.Literal("openai"), - Type.Literal("openrouter"), - Type.Literal("zai"), - Type.Literal("qwen"), - Type.Literal("qwen-chat-template"), - ]), - ), - openRouterRouting: Type.Optional(OpenRouterRoutingSchema), - vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), - extraBody: Type.Optional(Type.Record(Type.String(), Type.Unknown())), - supportsStrictMode: Type.Optional(Type.Boolean()), - toolStrictMode: Type.Optional(Type.Union([Type.Literal("all_strict"), Type.Literal("none")])), -}); - -const EffortSchema = Type.Union([ - Type.Literal("minimal"), - Type.Literal("low"), - Type.Literal("medium"), - Type.Literal("high"), - Type.Literal("xhigh"), -]); - -const ThinkingControlModeSchema = Type.Union([ - Type.Literal("effort"), - Type.Literal("budget"), - Type.Literal("google-level"), - Type.Literal("anthropic-adaptive"), - Type.Literal("anthropic-budget-effort"), -]); - -const ModelThinkingSchema = Type.Object({ - minLevel: EffortSchema, - maxLevel: EffortSchema, - mode: ThinkingControlModeSchema, - defaultLevel: Type.Optional(EffortSchema), -}); - -// Schema for custom model definition -// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.) -const ModelDefinitionSchema = Type.Object({ - id: Type.String({ minLength: 1 }), - name: Type.Optional(Type.String({ minLength: 1 })), - api: Type.Optional( - Type.Union([ - Type.Literal("openai-completions"), - Type.Literal("openai-responses"), - Type.Literal("openai-codex-responses"), - Type.Literal("azure-openai-responses"), - Type.Literal("anthropic-messages"), - Type.Literal("google-generative-ai"), - Type.Literal("google-vertex"), - ]), - ), - baseUrl: Type.Optional(Type.String({ minLength: 1 })), - reasoning: Type.Optional(Type.Boolean()), - thinking: Type.Optional(ModelThinkingSchema), - input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), - cost: Type.Optional( - Type.Object({ - input: Type.Number(), - output: Type.Number(), - cacheRead: Type.Number(), - cacheWrite: Type.Number(), - }), - ), - premiumMultiplier: Type.Optional(Type.Number()), - contextWindow: Type.Optional(Type.Number()), - maxTokens: Type.Optional(Type.Number()), - headers: Type.Optional(Type.Record(Type.String(), Type.String())), - compat: Type.Optional(OpenAICompatSchema), - contextPromotionTarget: Type.Optional(Type.String({ minLength: 1 })), -}); - -// Schema for per-model overrides (all fields optional, merged with built-in model) -const ModelOverrideSchema = Type.Object({ - name: Type.Optional(Type.String({ minLength: 1 })), - reasoning: Type.Optional(Type.Boolean()), - thinking: Type.Optional(ModelThinkingSchema), - input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), - cost: Type.Optional( - Type.Object({ - input: Type.Optional(Type.Number()), - output: Type.Optional(Type.Number()), - cacheRead: Type.Optional(Type.Number()), - cacheWrite: Type.Optional(Type.Number()), - }), - ), - premiumMultiplier: Type.Optional(Type.Number()), - contextWindow: Type.Optional(Type.Number()), - maxTokens: Type.Optional(Type.Number()), - headers: Type.Optional(Type.Record(Type.String(), Type.String())), - compat: Type.Optional(OpenAICompatSchema), - contextPromotionTarget: Type.Optional(Type.String({ minLength: 1 })), -}); - -type ModelOverride = Static; - -const ProviderDiscoverySchema = Type.Object({ - type: Type.Union([ - Type.Literal("ollama"), - Type.Literal("llama.cpp"), - Type.Literal("lm-studio"), - Type.Literal("openai-models-list"), - ]), -}); - -const ProviderAuthSchema = Type.Union([Type.Literal("apiKey"), Type.Literal("none"), Type.Literal("oauth")]); - -const ProviderConfigSchema = Type.Object({ - baseUrl: Type.Optional(Type.String({ minLength: 1 })), - apiKey: Type.Optional(Type.String({ minLength: 1 })), - api: Type.Optional( - Type.Union([ - Type.Literal("openai-completions"), - Type.Literal("openai-responses"), - Type.Literal("openai-codex-responses"), - Type.Literal("azure-openai-responses"), - Type.Literal("anthropic-messages"), - Type.Literal("google-generative-ai"), - Type.Literal("google-vertex"), - ]), - ), - headers: Type.Optional(Type.Record(Type.String(), Type.String())), - compat: Type.Optional(OpenAICompatSchema), - authHeader: Type.Optional(Type.Boolean()), - auth: Type.Optional(ProviderAuthSchema), - discovery: Type.Optional(ProviderDiscoverySchema), - models: Type.Optional(Type.Array(ModelDefinitionSchema)), - modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)), - /** When true, disables strict tool schemas for this provider (for third-party Anthropic-compatible endpoints that reject the strict field). */ - disableStrictTools: Type.Optional(Type.Boolean()), -}); - -const EquivalenceConfigSchema = Type.Object({ - overrides: Type.Optional(Type.Record(Type.String(), Type.String({ minLength: 1 }))), - exclude: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), -}); - -const ModelsConfigSchema = Type.Object({ - providers: Type.Optional(Type.Record(Type.String(), ProviderConfigSchema)), - equivalence: Type.Optional(EquivalenceConfigSchema), -}); - -type ModelsConfig = Static; - -type ProviderAuthMode = Static; -type ProviderDiscovery = Static; - type ProviderValidationMode = "models-config" | "runtime-register"; interface ProviderValidationModel { diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts new file mode 100644 index 000000000..29c136618 --- /dev/null +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -0,0 +1,162 @@ +import * as z from "zod/v4"; + +const OpenRouterRoutingSchema = z.object({ + only: z.array(z.string()).optional(), + order: z.array(z.string()).optional(), +}); + +const VercelGatewayRoutingSchema = z.object({ + only: z.array(z.string()).optional(), + order: z.array(z.string()).optional(), +}); + +const ReasoningEffortMapSchema = z.object({ + minimal: z.string().optional(), + low: z.string().optional(), + medium: z.string().optional(), + high: z.string().optional(), + xhigh: z.string().optional(), +}); + +export const OpenAICompatSchema = z.object({ + supportsStore: z.boolean().optional(), + supportsDeveloperRole: 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(), + requiresAssistantContentForToolCalls: z.boolean().optional(), + supportsToolChoice: z.boolean().optional(), + disableReasoningOnForcedToolChoice: 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(), + supportsStrictMode: z.boolean().optional(), + toolStrictMode: z.enum(["all_strict", "none"]).optional(), +}); + +const EffortSchema = z.enum(["minimal", "low", "medium", "high", "xhigh"]); + +const ThinkingControlModeSchema = z.enum([ + "effort", + "budget", + "google-level", + "anthropic-adaptive", + "anthropic-budget-effort", +]); + +const ModelThinkingSchema = z.object({ + minLevel: EffortSchema, + maxLevel: EffortSchema, + mode: ThinkingControlModeSchema, + defaultLevel: EffortSchema.optional(), +}); + +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-vertex", + ]) + .optional(), + baseUrl: z.string().min(1).optional(), + reasoning: z.boolean().optional(), + thinking: ModelThinkingSchema.optional(), + input: z.array(z.enum(["text", "image"])).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(), + headers: z.record(z.string(), z.string()).optional(), + compat: OpenAICompatSchema.optional(), + contextPromotionTarget: z.string().min(1).optional(), +}); + +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(), + 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(), + headers: z.record(z.string(), z.string()).optional(), + compat: OpenAICompatSchema.optional(), + contextPromotionTarget: z.string().min(1).optional(), +}); + +export type ModelOverride = z.infer; + +export const ProviderDiscoverySchema = z.object({ + type: z.enum(["ollama", "llama.cpp", "lm-studio", "openai-models-list"]), +}); + +export const ProviderAuthSchema = z.enum(["apiKey", "none", "oauth"]); + +export type ProviderAuthMode = z.infer; +export type ProviderDiscovery = z.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-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 EquivalenceConfigSchema = z.object({ + overrides: z.record(z.string(), z.string().min(1)).optional(), + exclude: z.array(z.string().min(1)).optional(), +}); + +export const ModelsConfigSchema = z.object({ + providers: z.record(z.string(), ProviderConfigSchema).optional(), + equivalence: EquivalenceConfigSchema.optional(), +}); + +export type ModelsConfig = z.infer; diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 7c193e2d9..e1e40360b 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.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 type { Static } from "@sinclair/typebox"; +import type * as z from "zod/v4"; import { executeHashlineSingle, HashlineMismatchError, @@ -53,7 +53,7 @@ type TInput = | typeof vimSchema | typeof applyPatchSchema; -type VimParams = Static; +type VimParams = z.infer; type EditParams = ReplaceParams | PatchParams | HashlineParams | VimParams | ApplyPatchParams; type EditToolResultDetails = EditToolDetails | VimToolDetails; diff --git a/packages/coding-agent/src/edit/modes/apply-patch.ts b/packages/coding-agent/src/edit/modes/apply-patch.ts index 6aaad0b18..c7facca59 100644 --- a/packages/coding-agent/src/edit/modes/apply-patch.ts +++ b/packages/coding-agent/src/edit/modes/apply-patch.ts @@ -8,19 +8,20 @@ * the `patch` mode. */ -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { parseApplyPatch, parseApplyPatchStreaming } from "../apply-patch/parser"; import { ApplyPatchError } from "../diff"; import type { PatchEditEntry } from "./patch"; -export const applyPatchSchema = Type.Object({ - input: Type.String({ - description: +export const applyPatchSchema = z.object({ + input: z + .string() + .describe( "Full Codex apply_patch envelope, including '*** Begin Patch' and '*** End Patch'. Contains any mix of Add/Delete/Update (with optional Move to) file operations.", - }), + ), }); -export type ApplyPatchParams = Static; +export type ApplyPatchParams = z.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 6eb96b631..f935f652a 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -8,9 +8,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { type FileDiagnosticsResult, flushLspWritethroughBatch, @@ -1577,29 +1576,23 @@ export async function computePatchDiff( } } -export const patchEditEntrySchema = Type.Object( - { - op: Type.Optional( - StringEnum(["create", "delete", "update"], { - description: "Operation (default: update)", - }), - ), - rename: Type.Optional(Type.String({ description: "New path for move" })), - diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), - }, - { additionalProperties: false }, -); +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 (update) or full content (create)").optional(), + }) + .strict(); -export const patchEditSchema = Type.Object( - { - path: Type.String({ description: "file path for edits" }), - edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }), - }, - { additionalProperties: false }, -); +export const patchEditSchema = z + .object({ + path: z.string().describe("file path for edits"), + edits: z.array(patchEditEntrySchema).min(1).describe("Patch operations"), + }) + .strict(); -export type PatchEditEntry = Static; -export type PatchParams = Static; +export type PatchEditEntry = z.infer; +export type PatchParams = z.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 ad0782941..00f8229b0 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; @@ -976,25 +976,23 @@ export function findContextLine( return { index: undefined, confidence: bestScore }; } -export const replaceEditEntrySchema = Type.Object( - { - old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), - new_text: Type.String({ description: "Replacement text" }), - all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), - }, - { additionalProperties: false }, -); +export const replaceEditEntrySchema = z + .object({ + old_text: z.string().describe("Text to find (fuzzy whitespace matching enabled)"), + new_text: z.string().describe("Replacement text"), + all: z.boolean().describe("Replace all occurrences (default: unique match required)").optional(), + }) + .strict(); -export const replaceEditSchema = Type.Object( - { - path: Type.String({ description: "file path for edits" }), - edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }), - }, - { additionalProperties: false }, -); +export const replaceEditSchema = z + .object({ + path: z.string().describe("file path for edits"), + edits: z.array(replaceEditEntrySchema).min(1).describe("Replacements"), + }) + .strict(); -export type ReplaceEditEntry = Static; -export type ReplaceParams = Static; +export type ReplaceEditEntry = z.infer; +export type ReplaceParams = z.infer; export interface ExecuteReplaceSingleOptions { session: ToolSession; diff --git a/packages/coding-agent/src/exa/factory.ts b/packages/coding-agent/src/exa/factory.ts index 108f4da88..8a329f45a 100644 --- a/packages/coding-agent/src/exa/factory.ts +++ b/packages/coding-agent/src/exa/factory.ts @@ -1,7 +1,7 @@ /** * Shared factory for creating Exa tools with consistent error handling and response formatting. */ -import type { TObject, TProperties } from "@sinclair/typebox"; +import type { TSchema } from "@oh-my-pi/pi-ai"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { callExaTool, findApiKey, formatGenericResponse, formatSearchResults, isSearchResponse } from "./mcp-client"; import type { ExaRenderDetails } from "./types"; @@ -11,7 +11,7 @@ export function createExaTool( name: string, label: string, description: string, - parameters: TObject, + parameters: TSchema, mcpToolName: string, options?: { /** When true, checks isSearchResponse and formats with formatSearchResults. Default: true */ @@ -19,7 +19,7 @@ export function createExaTool( /** Transform params before passing to callExaTool */ transformParams?: (params: Record) => Record; }, -): CustomTool { +): CustomTool { const formatResponse = options?.formatResponse ?? true; const transformParams = options?.transformParams; @@ -32,7 +32,8 @@ export function createExaTool( try { const apiKey = findApiKey(); // Exa MCP endpoint is publicly accessible; API key is optional - const args = transformParams ? transformParams(params as Record) : params; + const rawArgs = params as Record; + const args = transformParams ? transformParams(rawArgs) : rawArgs; const response = await callExaTool(mcpToolName, args, apiKey); if (formatResponse && isSearchResponse(response)) { diff --git a/packages/coding-agent/src/exa/mcp-client.ts b/packages/coding-agent/src/exa/mcp-client.ts index 960cebf61..3ee80ae1f 100644 --- a/packages/coding-agent/src/exa/mcp-client.ts +++ b/packages/coding-agent/src/exa/mcp-client.ts @@ -1,5 +1,5 @@ +import type { TSchema } from "@oh-my-pi/pi-ai"; import { $env, logger } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; import type { CustomTool, CustomToolResult } from "../extensibility/custom-tools/types"; import { callMCP } from "../mcp/json-rpc"; import type { diff --git a/packages/coding-agent/src/exa/researcher.ts b/packages/coding-agent/src/exa/researcher.ts index ad511a98c..64173249b 100644 --- a/packages/coding-agent/src/exa/researcher.ts +++ b/packages/coding-agent/src/exa/researcher.ts @@ -3,7 +3,8 @@ * * Async research tasks with polling for completion. */ -import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@oh-my-pi/pi-ai"; +import * as z from "zod/v4"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { createExaTool } from "./factory"; import type { ExaRenderDetails } from "./types"; @@ -12,22 +13,10 @@ const researcherStartTool = createExaTool( "exa_researcher_start", "Start Deep Research", "Start an asynchronous deep research task using Exa's researcher. Returns a task_id for polling completion.", - Type.Object({ - query: Type.String({ description: "Research query to investigate" }), - depth: Type.Optional( - Type.Number({ - description: "Research depth (1-5, default: 3)", - minimum: 1, - maximum: 5, - }), - ), - breadth: Type.Optional( - Type.Number({ - description: "Research breadth (1-5, default: 3)", - minimum: 1, - maximum: 5, - }), - ), + z.object({ + query: z.string().describe("Research query to investigate"), + depth: z.number().int().min(1).max(5).describe("Research depth (1-5, default: 3)").optional(), + breadth: z.number().int().min(1).max(5).describe("Research breadth (1-5, default: 3)").optional(), }), "deep_researcher_start", { formatResponse: false }, @@ -37,11 +26,11 @@ const researcherPollTool = createExaTool( "exa_researcher_poll", "Poll Research Status", "Poll the status of an asynchronous research task. Returns status (pending|running|completed|failed) and result if completed.", - Type.Object({ - task_id: Type.String({ description: "Task ID returned from exa_researcher_start" }), + z.object({ + task_id: z.string().describe("Task ID returned from exa_researcher_start"), }), "deep_researcher_check", { formatResponse: false }, ); -export const researcherTools: CustomTool[] = [researcherStartTool, researcherPollTool]; +export const researcherTools: CustomTool[] = [researcherStartTool, researcherPollTool]; diff --git a/packages/coding-agent/src/exa/search.ts b/packages/coding-agent/src/exa/search.ts index 1e007c3e8..73990fa15 100644 --- a/packages/coding-agent/src/exa/search.ts +++ b/packages/coding-agent/src/exa/search.ts @@ -3,8 +3,8 @@ * * Basic neural/keyword search, deep research, code search, and URL crawling. */ -import { StringEnum } from "@oh-my-pi/pi-ai"; -import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@oh-my-pi/pi-ai"; +import * as z from "zod/v4"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { createExaTool } from "./factory"; import type { ExaRenderDetails } from "./types"; @@ -29,57 +29,31 @@ Parameters: - highlights: Include highlighted relevant snippets (default: false) - num_results: Maximum number of results to return (default: 10, max: 100)`, - Type.Object({ - query: Type.String({ description: "Search query" }), - type: Type.Optional( - StringEnum(["keyword", "neural", "auto"], { - description: "Search type - neural (semantic), keyword (exact), or auto", - }), - ), - include_domains: Type.Optional( - Type.Array(Type.String(), { - description: "Only include results from these domains", - }), - ), - exclude_domains: Type.Optional( - Type.Array(Type.String(), { - description: "Exclude results from these domains", - }), - ), - start_published_date: Type.Optional( - Type.String({ - description: "Filter results published after this date (ISO 8601 format)", - }), - ), - end_published_date: Type.Optional( - Type.String({ - description: "Filter results published before this date (ISO 8601 format)", - }), - ), - use_autoprompt: Type.Optional( - Type.Boolean({ - description: "Let Exa optimize your query automatically (default: true)", - }), - ), - text: Type.Optional( - Type.Boolean({ - description: "Include page text content in results (costs more, default: false)", - }), - ), - highlights: Type.Optional( - Type.Boolean({ - description: "Include highlighted relevant snippets (default: false)", - }), - ), - num_results: Type.Optional( - Type.Number({ - description: "Maximum number of results to return (default: 10, max: 100)", - minimum: 1, - maximum: 100, - }), - ), + z.object({ + query: z.string().describe("Search query"), + type: z + .enum(["keyword", "neural", "auto"]) + .describe("Search type - neural (semantic), keyword (exact), or auto") + .optional(), + include_domains: z.array(z.string()).describe("Only include results from these domains").optional(), + exclude_domains: z.array(z.string()).describe("Exclude results from these domains").optional(), + start_published_date: z + .string() + .describe("Filter results published after this date (ISO 8601 format)") + .optional(), + end_published_date: z.string().describe("Filter results published before this date (ISO 8601 format)").optional(), + use_autoprompt: z.boolean().describe("Let Exa optimize your query automatically (default: true)").optional(), + text: z.boolean().describe("Include page text content in results (costs more, default: false)").optional(), + highlights: z.boolean().describe("Include highlighted relevant snippets (default: false)").optional(), + num_results: z + .number() + .int() + .min(1) + .max(100) + .describe("Maximum number of results to return (default: 10, max: 100)") + .optional(), }), "web_search_exa", ); -export const searchTools: CustomTool[] = [exaSearchTool]; +export const searchTools: CustomTool[] = [exaSearchTool]; diff --git a/packages/coding-agent/src/exa/types.ts b/packages/coding-agent/src/exa/types.ts index 7f6e548bc..f85068f9a 100644 --- a/packages/coding-agent/src/exa/types.ts +++ b/packages/coding-agent/src/exa/types.ts @@ -3,7 +3,7 @@ * * Types for the Exa MCP client and tool implementations. */ -import type { TSchema } from "@sinclair/typebox"; +import type { TSchema } from "@oh-my-pi/pi-ai"; /** MCP endpoint URLs */ export const EXA_MCP_URL = "https://mcp.exa.ai/mcp"; diff --git a/packages/coding-agent/src/exa/websets.ts b/packages/coding-agent/src/exa/websets.ts index 070a832f1..84c356be5 100644 --- a/packages/coding-agent/src/exa/websets.ts +++ b/packages/coding-agent/src/exa/websets.ts @@ -3,7 +3,8 @@ * * CRUD operations for websets, items, searches, enrichments, and monitoring. */ -import { type TObject, type TProperties, Type } from "@sinclair/typebox"; +import type { TSchema } from "@oh-my-pi/pi-ai"; +import * as z from "zod/v4"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { callWebsetsTool, findApiKey } from "./mcp-client"; import type { ExaRenderDetails } from "./types"; @@ -13,9 +14,9 @@ function createWebsetTool( name: string, label: string, description: string, - parameters: TObject, + parameters: TSchema, mcpToolName: string, -): CustomTool { +): CustomTool { return { name, label, @@ -51,9 +52,9 @@ const websetCreateTool = createWebsetTool( "webset_create", "Create Webset", "Create a new webset collection for organizing web content.", - Type.Object({ - name: Type.String({ description: "Name of the webset" }), - description: Type.Optional(Type.String({ description: "Optional description" })), + z.object({ + name: z.string().describe("Name of the webset"), + description: z.string().describe("Optional description").optional(), }), "create_webset", ); @@ -62,7 +63,7 @@ const websetListTool = createWebsetTool( "webset_list", "List Websets", "List all websets in your account.", - Type.Object({}), + z.object({}), "list_websets", ); @@ -70,8 +71,8 @@ const websetGetTool = createWebsetTool( "webset_get", "Get Webset", "Get details of a specific webset by ID.", - Type.Object({ - id: Type.String({ description: "Webset ID" }), + z.object({ + id: z.string().describe("Webset ID"), }), "get_webset", ); @@ -80,10 +81,10 @@ const websetUpdateTool = createWebsetTool( "webset_update", "Update Webset", "Update a webset's name or description.", - Type.Object({ - id: Type.String({ description: "Webset ID" }), - name: Type.Optional(Type.String({ description: "New name" })), - description: Type.Optional(Type.String({ description: "New description" })), + z.object({ + id: z.string().describe("Webset ID"), + name: z.string().describe("New name").optional(), + description: z.string().describe("New description").optional(), }), "update_webset", ); @@ -92,8 +93,8 @@ const websetDeleteTool = createWebsetTool( "webset_delete", "Delete Webset", "Delete a webset and all its contents.", - Type.Object({ - id: Type.String({ description: "Webset ID" }), + z.object({ + id: z.string().describe("Webset ID"), }), "delete_webset", ); @@ -103,10 +104,10 @@ const websetItemsListTool = createWebsetTool( "webset_items_list", "List Webset Items", "List items in a webset with optional pagination.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - limit: Type.Optional(Type.Number({ description: "Number of items to return" })), - offset: Type.Optional(Type.Number({ description: "Pagination offset" })), + z.object({ + webset_id: z.string().describe("Webset ID"), + limit: z.number().describe("Number of items to return").optional(), + offset: z.number().describe("Pagination offset").optional(), }), "list_webset_items", ); @@ -115,9 +116,9 @@ const websetItemGetTool = createWebsetTool( "webset_item_get", "Get Webset Item", "Get a specific item from a webset.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - item_id: Type.String({ description: "Item ID" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + item_id: z.string().describe("Item ID"), }), "get_item", ); @@ -127,9 +128,9 @@ const websetSearchCreateTool = createWebsetTool( "webset_search_create", "Create Webset Search", "Create a new search within a webset.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - query: Type.String({ description: "Search query" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + query: z.string().describe("Search query"), }), "create_search", ); @@ -138,9 +139,9 @@ const websetSearchGetTool = createWebsetTool( "webset_search_get", "Get Webset Search", "Get the status and results of a webset search.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - search_id: Type.String({ description: "Search ID" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + search_id: z.string().describe("Search ID"), }), "get_search", ); @@ -149,9 +150,9 @@ const websetSearchCancelTool = createWebsetTool( "webset_search_cancel", "Cancel Webset Search", "Cancel a running webset search.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - search_id: Type.String({ description: "Search ID" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + search_id: z.string().describe("Search ID"), }), "cancel_search", ); @@ -161,10 +162,10 @@ const websetEnrichmentCreateTool = createWebsetTool( "webset_enrichment_create", "Create Enrichment", "Create a new enrichment task for a webset.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - name: Type.String({ description: "Enrichment name" }), - prompt: Type.String({ description: "Enrichment prompt" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + name: z.string().describe("Enrichment name"), + prompt: z.string().describe("Enrichment prompt"), }), "create_enrichment", ); @@ -173,9 +174,9 @@ const websetEnrichmentGetTool = createWebsetTool( "webset_enrichment_get", "Get Enrichment", "Get the status and results of an enrichment task.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - enrichment_id: Type.String({ description: "Enrichment ID" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + enrichment_id: z.string().describe("Enrichment ID"), }), "get_enrichment", ); @@ -184,11 +185,11 @@ const websetEnrichmentUpdateTool = createWebsetTool( "webset_enrichment_update", "Update Enrichment", "Update an enrichment's name or prompt.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - enrichment_id: Type.String({ description: "Enrichment ID" }), - name: Type.Optional(Type.String({ description: "New name" })), - prompt: Type.Optional(Type.String({ description: "New prompt" })), + z.object({ + webset_id: z.string().describe("Webset ID"), + enrichment_id: z.string().describe("Enrichment ID"), + name: z.string().describe("New name").optional(), + prompt: z.string().describe("New prompt").optional(), }), "update_enrichment", ); @@ -197,9 +198,9 @@ const websetEnrichmentDeleteTool = createWebsetTool( "webset_enrichment_delete", "Delete Enrichment", "Delete an enrichment task.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - enrichment_id: Type.String({ description: "Enrichment ID" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + enrichment_id: z.string().describe("Enrichment ID"), }), "delete_enrichment", ); @@ -208,9 +209,9 @@ const websetEnrichmentCancelTool = createWebsetTool( "webset_enrichment_cancel", "Cancel Enrichment", "Cancel a running enrichment task.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - enrichment_id: Type.String({ description: "Enrichment ID" }), + z.object({ + webset_id: z.string().describe("Webset ID"), + enrichment_id: z.string().describe("Enrichment ID"), }), "cancel_enrichment", ); @@ -220,14 +221,14 @@ const websetMonitorCreateTool = createWebsetTool( "webset_monitor_create", "Create Monitor", "Create a monitoring task for a webset with optional webhook notifications.", - Type.Object({ - webset_id: Type.String({ description: "Webset ID" }), - webhook_url: Type.Optional(Type.String({ description: "Webhook URL for notifications" })), + z.object({ + webset_id: z.string().describe("Webset ID"), + webhook_url: z.string().describe("Webhook URL for notifications").optional(), }), "create_monitor", ); -export const websetsTools: CustomTool[] = [ +export const websetsTools: CustomTool[] = [ websetCreateTool, websetListTool, websetGetTool, diff --git a/packages/coding-agent/src/extensibility/custom-commands/loader.ts b/packages/coding-agent/src/extensibility/custom-commands/loader.ts index 28e314440..af14c706b 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/loader.ts @@ -1,15 +1,16 @@ /** * Custom command loader - loads TypeScript command modules using native Bun import. * - * Dependencies (@sinclair/typebox and pi-coding-agent) are injected via the CustomCommandAPI - * to avoid import resolution issues with custom commands loaded from user directories. + * Dependencies (the zod-backed typebox shim 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 * as typebox from "@sinclair/typebox"; +import * as zod from "zod/v4"; import { getConfigDirs } from "../../config"; import { execCommand } from "../../exec/exec"; +import * as typebox from "../typebox"; import { GreenCommand } from "./bundled/ci-green"; import { ReviewCommand } from "./bundled/review"; import type { @@ -183,6 +184,7 @@ export async function loadCustomCommands(options: LoadCustomCommandsOptions = {} exec: (command: string, args: string[], execOptions) => execCommand(command, args, execOptions?.cwd ?? cwd, execOptions), typebox, + zod, pi: await import("@oh-my-pi/pi-coding-agent"), }; diff --git a/packages/coding-agent/src/extensibility/custom-commands/types.ts b/packages/coding-agent/src/extensibility/custom-commands/types.ts index c368a7267..152afc8cd 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/types.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/types.ts @@ -19,8 +19,10 @@ export interface CustomCommandAPI { cwd: string; /** Execute a shell command */ exec(command: string, args: string[], options?: ExecOptions): Promise; - /** Injected @sinclair/typebox module */ - typebox: typeof import("@sinclair/typebox"); + /** Injected zod-backed typebox shim (legacy/compat). */ + typebox: typeof import("../typebox"); + /** Injected zod module for Zod-authored custom commands. */ + zod: typeof import("zod/v4"); /** Injected pi-coding-agent exports */ pi: typeof import("../.."); } diff --git a/packages/coding-agent/src/extensibility/custom-tools/loader.ts b/packages/coding-agent/src/extensibility/custom-tools/loader.ts index 3905f3360..a25c9a0f2 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/loader.ts @@ -1,19 +1,20 @@ /** * Custom tool loader - loads TypeScript tool modules using native Bun import. * - * Dependencies (@sinclair/typebox and pi-coding-agent) are injected via the CustomToolAPI - * to avoid import resolution issues with custom tools loaded from user directories. + * Dependencies (the zod-backed typebox shim and pi-coding-agent) are injected via the + * CustomToolAPI to avoid import resolution issues with custom tools loaded from user directories. */ 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 * as typebox from "@sinclair/typebox"; +import * as z from "zod/v4"; import { toolCapability } from "../../capability/tool"; import { type CustomTool, loadCapability } from "../../discovery"; import type { ExecOptions } from "../../exec/exec"; import { execCommand } from "../../exec/exec"; import type { HookUIContext } from "../../extensibility/hooks/types"; import { getAllPluginToolPaths } from "../../extensibility/plugins/loader"; +import * as typebox from "../typebox"; import { createNoOpUIContext, resolvePath } from "../utils"; import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types"; @@ -103,6 +104,7 @@ export class CustomToolLoader { hasUI: false, logger, typebox, + zod: z, 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 c1d1529ca..001f7901f 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -5,9 +5,8 @@ * They can provide custom rendering for tool calls and results in the TUI. */ import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import type { Model } from "@oh-my-pi/pi-ai"; +import type { Model, Static, TSchema } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import type { Static, TSchema } from "@sinclair/typebox"; import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; import type { Settings } from "../../config/settings"; @@ -52,8 +51,10 @@ export interface CustomToolAPI { hasUI: boolean; /** File logger for error/warning/debug messages */ logger: typeof import("@oh-my-pi/pi-utils").logger; - /** Injected @sinclair/typebox module */ - typebox: typeof import("@sinclair/typebox"); + /** Injected zod-backed typebox shim (legacy/compat — Zod-authored tools are preferred). */ + typebox: typeof import("../typebox"); + /** Injected zod module for Zod-authored custom tools. */ + zod: typeof import("zod/v4"); /** Injected pi-coding-agent exports */ pi: typeof import("../.."); /** Push a preview action that can later be resolved with the hidden resolve tool */ @@ -180,7 +181,7 @@ export interface CustomTool { strict?: boolean; /** Description for LLM */ description: string; - /** Parameter schema (TypeBox) */ + /** Parameter schema (Zod or TypeBox; TypeBox is auto-lifted to Zod at registration). */ 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/custom-tools/wrapper.ts b/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts index 3483c6009..421b6851b 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/wrapper.ts @@ -2,7 +2,7 @@ * CustomToolAdapter wraps CustomTool instances into AgentTool for use with the agent. */ import type { AgentTool, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import type { Static, TSchema } from "@sinclair/typebox"; +import type { Static, TSchema } from "@oh-my-pi/pi-ai"; import type { Theme } from "../../modes/theme/theme"; import { applyToolProxy } from "../tool-proxy"; import type { CustomTool, CustomToolContext } from "./types"; diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index ccfd6e1e4..4bb767eb7 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -8,8 +8,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model, TextContent } 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 type { TSchema } from "@sinclair/typebox"; -import * as TypeBox from "@sinclair/typebox"; +import * as Zod from "zod/v4"; import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; import { loadCapability } from "../../discovery"; import { getExtensionNameFromPath } from "../../discovery/helpers"; @@ -19,6 +18,7 @@ import type { CustomMessage } from "../../session/messages"; import { EventBus } from "../../utils/event-bus"; import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat"; import { getAllPluginExtensionPaths } from "../plugins/loader"; +import * as TypeBox from "../typebox"; import { resolvePath } from "../utils"; import type { @@ -119,6 +119,7 @@ export class ExtensionRuntime implements IExtensionRuntime { class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { readonly logger = logger; readonly typebox = TypeBox; + readonly zod = Zod; readonly flagValues = new Map(); readonly pendingProviderRegistrations: Array<{ name: string; @@ -140,7 +141,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { this.extension.handlers.set(event, list); } - registerTool(tool: ToolDefinition): void { + registerTool< + TParams extends import("@oh-my-pi/pi-ai").TSchema = import("@oh-my-pi/pi-ai").TSchema, + TDetails = unknown, + >(tool: ToolDefinition): void { this.extension.tools.set(tool.name, { definition: tool, extensionPath: this.extension.path, diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index cfede6113..5062601a5 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -17,12 +17,13 @@ import type { Model, ProviderResponseMetadata, SimpleStreamOptions, + Static, TextContent, + TSchema, } from "@oh-my-pi/pi-ai"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types"; import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; -import type { Static, TSchema } from "@sinclair/typebox"; import type { KeybindingsManager } from "../../config/keybindings"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; @@ -355,7 +356,7 @@ export interface ToolDefinition = 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; + +// --------------------------------------------------------------------------- +// Option shapes — loose subset of JSON Schema metadata + per-type constraints. +// --------------------------------------------------------------------------- + +interface Meta { + title?: string; + description?: string; + default?: unknown; + examples?: unknown[]; + // Real TypeBox accepts arbitrary extra JSON Schema keywords; we tolerate + // them silently so callers don't blow up on niche metadata. + [key: string]: unknown; +} + +interface StringOpts extends Meta { + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; +} + +interface NumberOpts extends Meta { + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; +} + +interface ArrayOpts extends Meta { + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; +} + +interface ObjectOpts extends Meta { + /** When false (TypeBox default), forbid extra keys. When true, allow any. */ + additionalProperties?: boolean | ZodType; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function withMeta(schema: T, opts: Meta | undefined): T { + if (!opts) return schema; + let out: ZodType = schema; + 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; + return out as T; +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +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; + 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; + default: + break; + } + // All TypeBox string formats are still ZodString subclasses, so .min/.max/.regex apply. + if (s instanceof z.ZodString) { + if (typeof opts.minLength === "number") s = s.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; +} + +function tNumber(opts?: NumberOpts): ZodType { + return withMeta(applyNumberConstraints(z.number(), opts), opts); +} + +function tInteger(opts?: NumberOpts): ZodType { + return withMeta(applyNumberConstraints(z.number().int(), opts), opts); +} + +function tBoolean(opts?: Meta): ZodType { + return withMeta(z.boolean(), opts); +} + +function tNull(opts?: Meta): ZodType { + return withMeta(z.null(), opts); +} + +function tAny(opts?: Meta): ZodType { + return withMeta(z.any(), opts); +} + +function tUnknown(opts?: Meta): ZodType { + return withMeta(z.unknown(), opts); +} + +function tNever(opts?: Meta): ZodType { + return withMeta(z.never(), opts); +} + +function tLiteral(value: V, opts?: Meta): ZodType { + return withMeta(z.literal(value), opts); +} + +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); +} + +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); +} + +function tEnum>(values: T, opts?: Meta): ZodType { + // Accepts either a plain object (TS enum / record of name→value) or a + // pre-built array; both are tolerated by `z.enum`. We collapse to values + // because TypeBox's `Type.Enum` discards the keys for the JSON Schema. + const list = Array.isArray(values) ? (values as unknown as (string | number)[]) : Object.values(values); + return withMeta(z.enum(list as [string, ...string[]]), opts); +} + +function tArray(item: E, opts?: ArrayOpts): ZodType { + let arr = z.array(item); + if (opts) { + if (typeof opts.minItems === "number") arr = arr.min(opts.minItems); + if (typeof opts.maxItems === "number") arr = arr.max(opts.maxItems); + // `uniqueItems` is observably useful only at JSON Schema emit time — + // providers either honor it or ignore it. Zod has no native equivalent, + // so we encode it as schema metadata for the wire output to surface. + } + 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); + if (opts && opts.additionalProperties !== undefined) { + if (opts.additionalProperties === false) { + // `.strict()` would *reject* extra keys; for parity with the looser + // real-TypeBox behavior we keep the default (strip-on-parse) which + // still serializes to `additionalProperties: false`. + } else if (opts.additionalProperties === true) { + obj = obj.catchall(z.any()) as unknown as ZodObject

; + } else { + obj = obj.catchall(opts.additionalProperties) as unknown as ZodObject

; + } + } + return withMeta(obj, opts); +} + +function tRecord(_key: ZodType, value: V, opts?: Meta): ZodType { + // JSON Schema `Type.Record(K, V)` is always keyed by strings on the wire + // (no provider honors numeric keys), so we ignore the key schema beyond + // the implicit string constraint. + return withMeta(z.record(z.string(), value) as unknown as ZodType, opts); +} + +function tOptional(schema: E, _opts?: Meta): ZodOptional { + return isOptional(schema) ? (schema as unknown as ZodOptional) : (schema.optional() as ZodOptional); +} + +function tNullable(schema: E, opts?: Meta): ZodType { + return withMeta(schema.nullable() as ZodType, opts); +} + +function tReadonly(schema: E): E { + // TypeBox's `Type.Readonly` is purely a marker; runtime parsing is identical. + return schema; +} + +function tPartial

(obj: ZodObject

): ZodObject

{ + return obj.partial() as unknown as ZodObject

; +} + +function tRequired

(obj: ZodObject

): ZodObject

{ + return obj.required() as unknown as ZodObject

; +} + +function tPick

(obj: ZodObject

, keys: readonly K[]): ZodObject> { + const mask = Object.fromEntries(keys.map(k => [k as string, true])); + return 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 obj.omit(mask as never) as unknown as ZodObject>; +} + +function tComposite( + objects: readonly [ZodObject, ZodObject], +): ZodObject { + // `Type.Composite([A, B])` flattens objects into a single object schema + // rather than producing an intersection. Mirror that via Zod's extend. + const [a, b] = objects; + return a.extend(b.shape) as unknown as ZodObject; +} + +// --------------------------------------------------------------------------- +// Public `Type` namespace +// --------------------------------------------------------------------------- + +export const Type = { + String: tString, + Number: tNumber, + Integer: tInteger, + Boolean: tBoolean, + Null: tNull, + Any: tAny, + Unknown: tUnknown, + Never: tNever, + Literal: tLiteral, + Union: tUnion, + Intersect: tIntersect, + Enum: tEnum, + Array: tArray, + Tuple: tTuple, + Object: tObject, + Record: tRecord, + Optional: tOptional, + Nullable: tNullable, + Readonly: tReadonly, + Partial: tPartial, + Required: tRequired, + Pick: tPick, + Omit: tOmit, + Composite: tComposite, +} as const; + +export type TypeBuilder = typeof Type; + +/** Default namespace export so `import * as typebox from "./typebox"` still resolves the `Type` key. */ +export default { Type }; diff --git a/packages/coding-agent/src/goals/tools/goal-tool.ts b/packages/coding-agent/src/goals/tools/goal-tool.ts index d4811a242..f562ac7e7 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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,19 +14,13 @@ import { renderStatusLine, truncateToWidth } from "../../tui"; import { completionBudgetReport, remainingTokens } from "../runtime"; import type { Goal, GoalStatus, GoalToolDetails } from "../state"; -const goalSchema = Type.Object({ - op: Type.Union([Type.Literal("create"), Type.Literal("get"), Type.Literal("complete")], { - description: "Goal operation.", - }), - objective: Type.Optional(Type.String({ description: "Goal objective. Required when op=create." })), - token_budget: Type.Optional( - Type.Integer({ - description: "Optional positive token budget. Only honored when op=create.", - }), - ), +const goalSchema = z.object({ + op: z.union([z.literal("create"), z.literal("get"), z.literal("complete")]).describe("Goal operation."), + objective: z.string().describe("Goal objective. Required when op=create.").optional(), + token_budget: z.number().int().describe("Optional positive token budget. Only honored when op=create.").optional(), }); -export type GoalToolInput = Static; +export type GoalToolInput = z.infer; export interface GoalToolResponse { goal: Goal | null; diff --git a/packages/coding-agent/src/hashline/types.ts b/packages/coding-agent/src/hashline/types.ts index 2bb963572..0747a5db2 100644 --- a/packages/coding-agent/src/hashline/types.ts +++ b/packages/coding-agent/src/hashline/types.ts @@ -1,5 +1,4 @@ -import type { Static } from "@sinclair/typebox"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { LspBatchRequest } from "../edit/renderer"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../lsp"; import type { ToolSession } from "../tools"; @@ -26,8 +25,9 @@ export type HashlineEdit = | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number } | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }; -export const hashlineEditParamsSchema = Type.Object({ input: Type.String() }); -export type HashlineParams = Static; +/** `path` is accepted by the edit tool runtime; other extra keys are preserved. */ +export const hashlineEditParamsSchema = z.object({ input: z.string(), path: z.string().optional() }).passthrough(); +export type HashlineParams = z.infer; export interface HashlineStreamOptions { /** First line number to use when formatting (1-indexed). */ diff --git a/packages/coding-agent/src/lsp/types.ts b/packages/coding-agent/src/lsp/types.ts index c9b90025d..876ec5a09 100644 --- a/packages/coding-agent/src/lsp/types.ts +++ b/packages/coding-agent/src/lsp/types.ts @@ -1,49 +1,41 @@ -import { StringEnum } from "@oh-my-pi/pi-ai"; import type { ptree } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; // ============================================================================= // Tool Schema // ============================================================================= -export const lspSchema = Type.Object({ - action: StringEnum( - [ - "diagnostics", - "definition", - "references", - "hover", - "symbols", - "rename", - "rename_file", - "code_actions", - "type_definition", - "implementation", - "status", - "reload", - "capabilities", - "request", - ], - { description: "LSP operation" }, - ), - file: Type.Optional(Type.String({ description: "File path or source path for rename_file" })), - line: Type.Optional(Type.Number({ description: "Line number (1-indexed)" })), - symbol: Type.Optional(Type.String({ description: "Symbol/substring to locate on the line" })), - query: Type.Optional( - Type.String({ description: "Search query, code-action selector, or LSP method name for action=request" }), - ), - new_name: Type.Optional(Type.String({ description: "New name for rename, or destination path for rename_file" })), - apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true for rename/rename_file)" })), - timeout: Type.Optional(Type.Number({ description: "Request timeout in seconds" })), - payload: Type.Optional( - Type.String({ - description: - "JSON-encoded params for action=request. When omitted, params are auto-built from file/line/symbol.", - }), - ), +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 to locate on the line").optional(), + query: z.string().describe("Search query, code-action selector, or LSP method name for action=request").optional(), + new_name: z.string().describe("New name for rename, or destination path for rename_file").optional(), + apply: z.boolean().describe("Apply edits (default: true for rename/rename_file)").optional(), + timeout: z.number().describe("Request timeout in seconds").optional(), + payload: z + .string() + .describe("JSON-encoded params for action=request. When omitted, params are auto-built from file/line/symbol.") + .optional(), }); -export type LspParams = Static; +export type LspParams = z.infer; export interface LspToolDetails { serverName?: string; diff --git a/packages/coding-agent/src/mcp/manager.ts b/packages/coding-agent/src/mcp/manager.ts index 3e6995821..91260afe1 100644 --- a/packages/coding-agent/src/mcp/manager.ts +++ b/packages/coding-agent/src/mcp/manager.ts @@ -6,8 +6,8 @@ */ import * as path from "node:path"; import * as url from "node:url"; +import type { TSchema } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; import type { SourceMeta } from "../capability/types"; import { resolveConfigValue } from "../config/resolve-config-value"; import type { CustomTool } from "../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/src/mcp/tool-bridge.ts b/packages/coding-agent/src/mcp/tool-bridge.ts index c1c50906c..811bc4cff 100644 --- a/packages/coding-agent/src/mcp/tool-bridge.ts +++ b/packages/coding-agent/src/mcp/tool-bridge.ts @@ -4,9 +4,9 @@ * Converts MCP tool definitions to CustomTool format for the agent. */ import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { TSchema } from "@oh-my-pi/pi-ai"; import { sanitizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema"; import { untilAborted } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; import type { SourceMeta } from "../capability/types"; import type { CustomTool, diff --git a/packages/coding-agent/src/modes/rpc/host-tools.ts b/packages/coding-agent/src/modes/rpc/host-tools.ts index 9cc75ed1c..9fddb09f1 100644 --- a/packages/coding-agent/src/modes/rpc/host-tools.ts +++ b/packages/coding-agent/src/modes/rpc/host-tools.ts @@ -1,6 +1,6 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { Static, TSchema } from "@oh-my-pi/pi-ai"; import { Snowflake } from "@oh-my-pi/pi-utils"; -import type { Static, TSchema } from "@sinclair/typebox"; import { applyToolProxy } from "../../extensibility/tool-proxy"; import type { Theme } from "../../modes/theme/theme"; import type { diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index ed9e5ac4a..14ccaab61 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -11,9 +11,8 @@ import { } from "@oh-my-pi/pi-natives"; import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; import { adjustHsv, getCustomThemesDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; -import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; +import * as z from "zod/v4"; // Embed theme JSON files at build time import darkThemeJson from "./dark.json" with { type: "json" }; import { defaultThemes } from "./defaults"; @@ -807,118 +806,111 @@ const SPINNER_FRAMES: Record> = { // Types & Schema // ============================================================================ -const ColorValueSchema = Type.Union([ - Type.String(), // hex "#ff0000", var ref "primary", or empty "" - Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index +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 = Static; +type ColorValue = z.infer; -// Use Type.Union here (not StringEnum) because TypeCompiler doesn't support Type.Unsafe -const SymbolPresetSchema = Type.Union([Type.Literal("unicode"), Type.Literal("nerd"), Type.Literal("ascii")]); +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 SymbolsSchema = Type.Optional( - Type.Object({ - preset: Type.Optional(SymbolPresetSchema), - overrides: Type.Optional(Type.Record(Type.String(), Type.String())), - }), +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 ThemeJsonSchema = Type.Object({ - $schema: Type.Optional(Type.String()), - name: Type.String(), - vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), - colors: Type.Object({ - // Core UI (10 colors) - accent: ColorValueSchema, - border: ColorValueSchema, - borderAccent: ColorValueSchema, - borderMuted: ColorValueSchema, - success: ColorValueSchema, - error: ColorValueSchema, - warning: ColorValueSchema, - muted: ColorValueSchema, - dim: ColorValueSchema, - text: ColorValueSchema, - thinkingText: ColorValueSchema, - // Backgrounds & Content Text (11 colors) - selectedBg: ColorValueSchema, - userMessageBg: ColorValueSchema, - userMessageText: ColorValueSchema, - customMessageBg: ColorValueSchema, - customMessageText: ColorValueSchema, - customMessageLabel: ColorValueSchema, - toolPendingBg: ColorValueSchema, - toolSuccessBg: ColorValueSchema, - toolErrorBg: ColorValueSchema, - toolTitle: ColorValueSchema, - toolOutput: ColorValueSchema, - // Markdown (10 colors) - mdHeading: ColorValueSchema, - mdLink: ColorValueSchema, - mdLinkUrl: ColorValueSchema, - mdCode: ColorValueSchema, - mdCodeBlock: ColorValueSchema, - mdCodeBlockBorder: ColorValueSchema, - mdQuote: ColorValueSchema, - mdQuoteBorder: ColorValueSchema, - mdHr: ColorValueSchema, - mdListBullet: ColorValueSchema, - // Tool Diffs (3 colors) - toolDiffAdded: ColorValueSchema, - toolDiffRemoved: ColorValueSchema, - toolDiffContext: ColorValueSchema, - // Syntax Highlighting (9 colors) - syntaxComment: ColorValueSchema, - syntaxKeyword: ColorValueSchema, - syntaxFunction: ColorValueSchema, - syntaxVariable: ColorValueSchema, - syntaxString: ColorValueSchema, - syntaxNumber: ColorValueSchema, - syntaxType: ColorValueSchema, - syntaxOperator: ColorValueSchema, - syntaxPunctuation: ColorValueSchema, - // Thinking Level Borders (6 colors) - thinkingOff: ColorValueSchema, - thinkingMinimal: ColorValueSchema, - thinkingLow: ColorValueSchema, - thinkingMedium: ColorValueSchema, - thinkingHigh: ColorValueSchema, - thinkingXhigh: ColorValueSchema, - // Bash Mode (1 color) - bashMode: ColorValueSchema, - // Python Mode (1 color) - pythonMode: ColorValueSchema, - // Footer Status Line - statusLineBg: ColorValueSchema, - statusLineSep: ColorValueSchema, - statusLineModel: ColorValueSchema, - statusLinePath: ColorValueSchema, - statusLineGitClean: ColorValueSchema, - statusLineGitDirty: ColorValueSchema, - statusLineContext: ColorValueSchema, - statusLineSpend: ColorValueSchema, - statusLineStaged: ColorValueSchema, - statusLineDirty: ColorValueSchema, - statusLineUntracked: ColorValueSchema, - statusLineOutput: ColorValueSchema, - statusLineCost: ColorValueSchema, - statusLineSubagents: ColorValueSchema, - }), - export: Type.Optional( - Type.Object({ - pageBg: Type.Optional(ColorValueSchema), - cardBg: Type.Optional(ColorValueSchema), - infoBg: Type.Optional(ColorValueSchema), - }), - ), - symbols: SymbolsSchema, +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(), + }) + .optional(), }); -type ThemeJson = Static; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TypeBox CJS/ESM type mismatch -const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema as any); +type ThemeJson = z.infer; export type ThemeColor = | "accent" @@ -1638,18 +1630,20 @@ async function loadThemeJson(name: string): Promise { } catch (error) { throw new Error(`Failed to parse theme ${name}: ${error}`); } - if (!validateThemeJson.Check(json)) { - const errors = Array.from(validateThemeJson.Errors(json)); + const parsed = themeJsonSchema.safeParse(json); + if (!parsed.success) { const missingColors: string[] = []; const otherErrors: string[] = []; - for (const e of errors) { - // Check for missing required color properties - const match = e.path.match(/^\/colors\/(\w+)$/); - if (match && e.message.includes("Required")) { - missingColors.push(match[1]); + 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 { - otherErrors.push(` - ${e.path}: ${e.message}`); + const pathStr = parts.length === 0 ? "/" : `/${parts.map(String).join("/")}`; + otherErrors.push(` - ${pathStr}: ${issue.message}`); } } @@ -1666,7 +1660,7 @@ async function loadThemeJson(name: string): Promise { throw new Error(errorMessage); } - return json as ThemeJson; + return parsed.data; } interface CreateThemeOptions { diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 3fbd62fe2..9685c3ec3 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -7,9 +7,8 @@ import path from "node:path"; import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; +import { fromTypeBox } from "@oh-my-pi/pi-ai/utils/schema"; import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; -import Ajv, { type ValidateFunction } from "ajv"; import { ModelRegistry } from "../config/model-registry"; import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver"; import type { PromptTemplate } from "../config/prompt-templates"; @@ -52,7 +51,6 @@ import { } from "./types"; const MCP_CALL_TIMEOUT_MS = 60_000; -const ajv = new Ajv({ allErrors: true, strict: false, logger: false }); /** Agent event types to forward for progress tracking. */ const agentEventTypes = new Set([ @@ -206,13 +204,14 @@ function parseStringifiedJson(value: unknown): unknown { } } -function buildOutputValidator(schema: unknown): { validate?: ValidateFunction; error?: string } { +function buildOutputValidator(schema: unknown): { validate?: (value: unknown) => boolean; error?: string } { const { normalized, error } = normalizeSchema(schema); if (error) return { error }; if (normalized === undefined) return {}; const jsonSchema = jtdToJsonSchema(normalized); try { - return { validate: ajv.compile(jsonSchema as any) }; + const zod = fromTypeBox(jsonSchema); + return { validate: value => zod.safeParse(value).success }; } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } @@ -418,14 +417,14 @@ function getUsageTokens(usage: unknown): number { /** * Create proxy tools that reuse the parent's MCP connections. */ -function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] { +function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] { return mcpManager.getTools().map(tool => { const mcpTool = tool as { mcpToolName?: string; mcpServerName?: string }; return { name: tool.name, label: tool.label ?? tool.name, description: tool.description ?? "", - parameters: tool.parameters as TSchema, + parameters: tool.parameters, execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => { if (signal?.aborted) { throw new ToolAbortError(); diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index cfd7d86c9..d8d811448 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -18,7 +18,6 @@ import path from "node:path"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Usage } from "@oh-my-pi/pi-ai"; import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils"; -import type { TSchema } from "@sinclair/typebox"; import type { ToolSession } from ".."; import { AsyncJobManager } from "../async"; import { resolveAgentModelPatterns } from "../config/model-resolver"; @@ -29,6 +28,15 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" }; import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" }; import { formatBytes, formatDuration } from "../tools/render-utils"; +import { + type AgentDefinition, + type AgentProgress, + getTaskSchema, + type SingleResult, + type TaskParams, + type TaskToolDetails, + type TaskToolSchemaInstance, +} from "./types"; // Import review tools for side effects (registers subagent tool handlers) import "../tools/review"; import type { LocalProtocolOptions } from "../internal-urls"; @@ -40,14 +48,6 @@ import { AgentOutputManager } from "./output-manager"; import { mapWithConcurrencyLimit, Semaphore } from "./parallel"; import { renderResult, renderCall as renderTaskCall } from "./render"; import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode"; -import { - type AgentDefinition, - type AgentProgress, - getTaskSchema, - type SingleResult, - type TaskParams, - type TaskToolDetails, -} from "./types"; import { applyNestedPatches, captureBaseline, @@ -198,7 +198,7 @@ function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams): * Requires async initialization to discover available agents. * Use `TaskTool.create(session)` to instantiate. */ -export class TaskTool implements AgentTool { +export class TaskTool implements AgentTool { readonly name = "task"; readonly label = "Task"; readonly summary = "Spawn a subagent to complete a parallel task"; @@ -208,7 +208,7 @@ export class TaskTool implements AgentTool { readonly #discoveredAgents: AgentDefinition[]; readonly #blockedAgent: string | undefined; - get parameters(): TSchema { + get parameters(): TaskToolSchemaInstance { const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none"; return getTaskSchema({ isolationEnabled, simpleMode: this.#getTaskSimpleMode() }); } diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index bf9e19f29..1516b829f 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 { type Static, type TSchema, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode"; import type { NestedRepoPatch } from "./worktree"; @@ -63,65 +63,65 @@ const assignmentDescriptionForContextDisabled = "Complete per-task instructions the subagent executes. Must follow the Target/Change/Edge Cases/Acceptance structure, and include any background that would otherwise live in `context` since shared context is disabled in this mode."; const createTaskItemSchema = (contextEnabled: boolean) => - Type.Object({ - id: Type.String({ - description: "CamelCase identifier, max 48 chars", - maxLength: 48, - }), - description: Type.String({ - description: "Short one-liner for UI display only — not seen by the subagent", - }), - assignment: Type.String({ - description: contextEnabled ? assignmentDescriptionForContextEnabled : assignmentDescriptionForContextDisabled, - }), + z.object({ + id: z.string().max(48).describe("CamelCase identifier, max 48 chars"), + description: z.string().describe("Short one-liner for UI display only — not seen by the subagent"), + assignment: z + .string() + .describe(contextEnabled ? assignmentDescriptionForContextEnabled : assignmentDescriptionForContextDisabled), }); /** Single task item for parallel execution (default shape with context enabled). */ export const taskItemSchema = createTaskItemSchema(true); -export type TaskItem = Static; +export type TaskItem = z.infer; const createTaskSchema = (options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }) => { const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(options.simpleMode); const itemSchema = createTaskItemSchema(contextEnabled); - const properties: Record = { - agent: Type.String({ description: "Agent type for all tasks in this batch" }), - tasks: Type.Array(itemSchema, { - description: contextEnabled - ? "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and self-contained given context + assignment." - : "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and fully self-contained inside assignment because shared context is disabled.", - }), - }; + + let schema = z.object({ + agent: z.string().describe("Agent type for all tasks in this batch"), + tasks: z + .array(itemSchema) + .describe( + contextEnabled + ? "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and self-contained given context + assignment." + : "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and fully self-contained inside assignment because shared context is disabled.", + ), + }); if (contextEnabled) { - properties.context = Type.Optional( - Type.String({ - description: + schema = schema.extend({ + context: z + .string() + .optional() + .describe( "Shared background prepended to every task's assignment. Put goal, non-goals, constraints, conventions, reference paths, API contracts, and global acceptance commands here once — instead of duplicating across assignments.", - }), - ); - } - - if (customSchemaEnabled) { - properties.schema = Type.Optional( - Type.String({ - description: - "JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.", - }), - ); - } - - if (options.isolationEnabled) { - return Type.Object({ - ...properties, - isolated: Type.Optional( - Type.Boolean({ - description: "Run in isolated environment; returns patches. Use when tasks edit overlapping files.", - }), - ), + ), }); } - return Type.Object(properties); + if (customSchemaEnabled) { + schema = schema.extend({ + schema: z + .string() + .optional() + .describe( + "JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.", + ), + }); + } + + if (options.isolationEnabled) { + schema = schema.extend({ + isolated: z + .boolean() + .optional() + .describe("Run in isolated environment; returns patches. Use when tasks edit overlapping files."), + }); + } + + return schema; }; export const taskSchema = createTaskSchema({ isolationEnabled: true, simpleMode: "default" }); @@ -141,6 +141,8 @@ const ALL_TASK_SCHEMAS = [ type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number]; export type TaskSchema = typeof taskSchema; +/** Active task tool parameter schema for the current simple-mode / isolation flags */ +export type TaskToolSchemaInstance = DynamicTaskSchema; export function getTaskSchema(options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }): DynamicTaskSchema { switch (options.simpleMode) { diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 2e716c3aa..bc1bc1527 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -18,7 +18,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme"; import askDescription from "../prompts/tools/ask.md" with { type: "text" }; @@ -31,23 +31,23 @@ import { ToolAbortError } from "./tool-errors"; // Types // ============================================================================= -const OptionItem = Type.Object({ - label: Type.String({ description: "display label" }), +const OptionItem = z.object({ + label: z.string().describe("display label"), }); -const QuestionItem = Type.Object({ - id: Type.String({ description: "question id", examples: ["auth", "cache"] }), - question: Type.String({ description: "question text" }), - options: Type.Array(OptionItem, { description: "available options" }), - multi: Type.Optional(Type.Boolean({ description: "allow multiple selections" })), - recommended: Type.Optional(Type.Number({ description: "recommended option index" })), +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 askSchema = Type.Object({ - questions: Type.Array(QuestionItem, { description: "questions to ask", minItems: 1 }), +const askSchema = z.object({ + questions: z.array(QuestionItem).min(1).describe("questions to ask"), }); -export type AskToolInput = Static; +export type AskToolInput = z.infer; /** Result for a single question */ export interface QuestionResult { diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 3be444d89..fdaa0fa7e 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -4,7 +4,7 @@ import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-m import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; import type { Theme } from "../modes/theme/theme"; @@ -33,21 +33,17 @@ import { queueResolveHandler } from "./resolve"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const astEditOpSchema = Type.Object({ - pat: Type.String({ description: "ast pattern", examples: ["oldFn($$$ARGS)"] }), - out: Type.String({ description: "replacement template", examples: ["newFn($$$ARGS)"] }), +const astEditOpSchema = z.object({ + pat: z.string().describe("ast pattern"), + out: z.string().describe("replacement template"), }); -const astEditSchema = Type.Object({ - ops: Type.Array(astEditOpSchema, { - minItems: 1, - description: "rewrite ops", - }), - paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to rewrite" }), { - minItems: 1, - description: "files, directories, globs, or internal URLs to rewrite", - examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]], - }), +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"), }); interface AstEditCallOptions { @@ -174,7 +170,7 @@ export class AstEditTool implements AgentTool, + params: z.infer, 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 3e0a2f05c..01914cad1 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -4,7 +4,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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; @@ -32,14 +32,13 @@ import { import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const astGrepSchema = Type.Object({ - pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }), - paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), { - minItems: 1, - description: "files, directories, globs, or internal URLs to search", - examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]], - }), - skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })), +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(), }); async function runMultiTargetAstGrep( @@ -129,7 +128,7 @@ export class AstGrepTool implements AgentTool, + params: z.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 075a1b442..c3eb5e17e 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -3,7 +3,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { Component } from "@oh-my-pi/pi-tui"; import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { $env, getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { AsyncJobManager } from "../async"; import { type BashResult, executeBash } from "../exec/bash-executor"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -44,30 +44,16 @@ async function saveBashOriginalArtifact(session: ToolSession, originalText: stri } } -const bashSchemaBase = Type.Object({ - command: Type.String({ description: "command to execute", examples: ["ls -la", "echo hi"] }), - env: Type.Optional( - Type.Record(Type.String({ pattern: BASH_ENV_NAME_PATTERN.source }), Type.String(), { - description: "extra env vars", - }), - ), - timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 300 })), - cwd: Type.Optional(Type.String({ description: "working directory", examples: ["src/", "/tmp"] })), - - pty: Type.Optional( - Type.Boolean({ - description: "run in pty mode", - }), - ), +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 bashSchemaWithAsync = Type.Object({ - ...bashSchemaBase.properties, - async: Type.Optional( - Type.Boolean({ - description: "run in background", - }), - ), +const bashSchemaWithAsync = bashSchemaBase.extend({ + async: z.boolean().describe("run in background").optional(), }); type BashToolSchema = typeof bashSchemaBase | typeof bashSchemaWithAsync; diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index d043ab892..8f2e2128b 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -1,7 +1,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import browserDescription from "../prompts/tools/browser.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry"; @@ -18,62 +17,49 @@ export type { Observation, ObservationEntry } from "./browser/tab-protocol"; const DEFAULT_TAB_NAME = "main"; -const appSchema = Type.Object({ - path: Type.Optional( - Type.String({ - description: "absolute path to a binary to spawn (single-instance reuse)", - examples: ["/Applications/Cursor.app/Contents/MacOS/Cursor"], - }), - ), - cdp_url: Type.Optional( - Type.String({ - description: "existing CDP endpoint to connect to (e.g. http://127.0.0.1:9222)", - }), - ), - args: Type.Optional(Type.Array(Type.String(), { description: "extra CLI args when spawning" })), - target: Type.Optional(Type.String({ description: "substring matched against url+title to pick a BrowserWindow" })), +const appSchema = z.object({ + path: z.string().describe("absolute path to a binary to spawn (single-instance reuse)").optional(), + cdp_url: z.string().describe("existing CDP endpoint to connect to (e.g. http://127.0.0.1:9222)").optional(), + args: z.array(z.string()).describe("extra CLI args when spawning").optional(), + target: z.string().describe("substring matched against url+title to pick a BrowserWindow").optional(), }); -const browserSchema = Type.Object({ - action: StringEnum(["open", "close", "run"], { description: "tab/browser operation" }), - name: Type.Optional( - Type.String({ - description: "tab id; default 'main'. Multiple tabs can coexist; reusable across run() calls and subagents.", - examples: ["main", "docs", "gh"], - }), - ), - url: Type.Optional(Type.String({ description: "open: navigate after acquiring tab" })), - app: Type.Optional(appSchema), - viewport: Type.Optional( - Type.Object({ - width: Type.Number(), - height: Type.Number(), - scale: Type.Optional(Type.Number()), - }), - ), - wait_until: Type.Optional( - StringEnum(["load", "domcontentloaded", "networkidle0", "networkidle2"], { - description: "navigation wait condition for url", - }), - ), - dialogs: Type.Optional( - StringEnum(["accept", "dismiss"], { - description: "open: auto-handle alert/confirm/beforeunload dialogs (default: leave for caller to handle)", - }), - ), - code: Type.Optional( - Type.String({ - description: - "run: JS body executed with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Treated as the body of an async function. Use `display(value)` to attach text/JSON/images; the function's return value is JSON-serialized as a final block.", - }), - ), - timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 30 })), - all: Type.Optional(Type.Boolean({ description: "close: close every tab" })), - kill: Type.Optional(Type.Boolean({ description: "close: also kill spawned-app browsers (default: leave running)" })), +const browserSchema = z.object({ + action: z.enum(["open", "close", "run"] as const).describe("tab/browser operation"), + name: z + .string() + .describe("tab id; default 'main'. Multiple tabs can coexist; reusable across run() calls and subagents.") + .optional(), + url: z.string().describe("open: navigate after acquiring tab").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 for url") + .optional(), + dialogs: z + .enum(["accept", "dismiss"] as const) + .describe("open: auto-handle alert/confirm/beforeunload dialogs (default: leave for caller to handle)") + .optional(), + code: z + .string() + .describe( + "run: JS body executed with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Treated as the body of an async function. Use `display(value)` to attach text/JSON/images; the function's return value is JSON-serialized as a final block.", + ) + .optional(), + timeout: z.number().default(30).describe("timeout in seconds").optional(), + all: z.boolean().describe("close: close every tab").optional(), + kill: z.boolean().describe("close: also kill spawned-app browsers (default: leave running)").optional(), }); /** Input schema for the browser tool. */ -export type BrowserParams = Static; +export type BrowserParams = z.infer; /** Details describing a browser tool execution result (for renderers + transcript). */ export interface BrowserToolDetails { diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 2cb5975ad..af4dae870 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import calculatorDescription from "../prompts/tools/calculator.md" with { type: "text" }; @@ -28,15 +28,16 @@ type Token = | { type: "operator"; value: Operator } | { type: "paren"; value: "(" | ")" }; -const calculatorSchema = Type.Object({ - calculations: Type.Array( - Type.Object({ - expression: Type.String({ description: "math expression", examples: ["2 + 2", "sqrt(16)"] }), - prefix: Type.String({ description: "prefix text" }), - suffix: Type.String({ description: "suffix text" }), - }), - { description: "calculations to evaluate" }, - ), +const calculatorSchema = z.object({ + calculations: z + .array( + z.object({ + expression: z.string().describe("math expression"), + prefix: z.string().describe("prefix text"), + suffix: z.string().describe("suffix text"), + }), + ) + .describe("calculations to evaluate"), }); export interface CalculatorToolDetails { @@ -385,7 +386,7 @@ function formatResult(value: number): string { // Tool Class // ═══════════════════════════════════════════════════════════════════════════ -type CalculatorParams = Static; +type CalculatorParams = z.infer; /** * Calculator tool for evaluating mathematical expressions. diff --git a/packages/coding-agent/src/tools/checkpoint.ts b/packages/coding-agent/src/tools/checkpoint.ts index 6ea27c8df..c97061f49 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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 = Type.Object({ - goal: Type.String({ description: "investigation goal", examples: ["investigate retry logic"] }), +const checkpointSchema = z.object({ + goal: z.string().describe("investigation goal"), }); -type CheckpointParams = Static; +type CheckpointParams = z.infer; -const rewindSchema = Type.Object({ - report: Type.String({ description: "investigation findings" }), +const rewindSchema = z.object({ + report: z.string().describe("investigation findings"), }); -type RewindParams = Static; +type RewindParams = z.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 95a558a10..04a87e09c 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -5,10 +5,9 @@ import type { AgentToolUpdateCallback, RenderResultOptions, } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { type DapBreakpointRecord, type DapCapabilities, @@ -51,8 +50,8 @@ import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; -const debugSchema = Type.Object({ - action: StringEnum([ +const debugSchema = z.object({ + action: z.enum([ "launch", "attach", "set_breakpoint", @@ -81,47 +80,45 @@ const debugSchema = Type.Object({ "output", "terminate", "sessions", - ]), - program: Type.Optional(Type.String({ description: "program path" })), - args: Type.Optional(Type.Array(Type.String(), { description: "program arguments" })), - adapter: Type.Optional(Type.String({ description: "debugger adapter (gdb, lldb-dap, debugpy, dlv)" })), - cwd: Type.Optional(Type.String()), - file: Type.Optional(Type.String({ description: "source file" })), - line: Type.Optional(Type.Number({ description: "source line" })), - function: Type.Optional(Type.String({ description: "function name" })), - name: Type.Optional(Type.String({ description: "variable or data name" })), - condition: Type.Optional(Type.String({ description: "breakpoint condition" })), - hit_condition: Type.Optional(Type.String()), - expression: Type.Optional(Type.String({ description: "expression to evaluate" })), - context: Type.Optional( - Type.String({ description: "evaluate context: watch | repl | hover | variables | clipboard" }), - ), - frame_id: Type.Optional(Type.Number()), - scope_id: Type.Optional(Type.Number({ description: "scope variables reference" })), - variable_ref: Type.Optional(Type.Number({ description: "variable reference" })), - pid: Type.Optional(Type.Number({ description: "process id for attach" })), - port: Type.Optional(Type.Number({ description: "remote attach port" })), - host: Type.Optional(Type.String({ description: "remote attach host" })), - levels: Type.Optional(Type.Number({ description: "max stack frames" })), - memory_reference: Type.Optional(Type.String({ description: "memory reference or address" })), - instruction_reference: Type.Optional(Type.String()), - instruction_count: Type.Optional(Type.Number()), - instruction_offset: Type.Optional(Type.Number()), - count: Type.Optional(Type.Number({ description: "bytes to read" })), - data: Type.Optional(Type.String({ description: "base64 memory payload" })), - data_id: Type.Optional(Type.String({ description: "data breakpoint id" })), - access_type: Type.Optional(StringEnum(["read", "write", "readWrite"])), - command: Type.Optional(Type.String({ description: "custom dap request command" })), - arguments: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "custom request arguments" })), - offset: Type.Optional(Type.Number()), - resolve_symbols: Type.Optional(Type.Boolean()), - allow_partial: Type.Optional(Type.Boolean()), - start_module: Type.Optional(Type.Number()), - module_count: Type.Optional(Type.Number()), - timeout: Type.Optional(Type.Number({ description: "per-request timeout seconds" })), + ] 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(), }); -export type DebugParams = Static; +export type DebugParams = z.infer; export type DebugAction = DebugParams["action"]; interface DebugToolDetails { diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index 85adcce1d..805091c66 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -3,7 +3,7 @@ import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Markdown, Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { jsBackend, parseEvalInput, pythonBackend, sniffEvalLanguage } from "../eval"; import type { ExecutorBackend } from "../eval/backend"; import evalGrammar from "../eval/eval.lark" with { type: "text" }; @@ -29,12 +29,10 @@ import { clampTimeout } from "./tool-timeouts"; export const EVAL_DEFAULT_PREVIEW_LINES = 10; -export const evalSchema = Type.Object({ - input: Type.String({ - description: 'eval input as a sequence of `*** Cell :"title"` cell headers followed by code', - }), +export const evalSchema = z.object({ + input: z.string().describe('eval input as a sequence of `*** Cell :"title"` cell headers followed by code'), }); -export type EvalToolParams = Static; +export type EvalToolParams = z.infer; export type EvalToolResult = { content: Array<{ type: "text"; text: string }>; @@ -228,7 +226,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 input = args.input; if (input) { try { @@ -254,7 +252,7 @@ export class EvalTool implements AgentTool { async execute( _toolCallId: string, - params: Static, + params: z.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 64cb858cf..9c4558856 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -5,8 +5,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 { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import type { Static } from "@sinclair/typebox"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; @@ -35,17 +34,15 @@ import { import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; -const findSchema = Type.Object({ - paths: Type.Array(Type.String({ description: "glob including search path" }), { - minItems: 1, - description: "globs including search paths", - examples: [["src/**/*.ts"], ["lib/*.json"], ["apps/", "packages/"], ["*.ts"]], - }), - hidden: Type.Optional(Type.Boolean({ description: "include hidden files", default: true })), - limit: Type.Optional(Type.Number({ description: "max results", default: 1000 })), -}); +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(), + limit: z.number().default(1000).describe("max results").optional(), + }) + .strict(); -export type FindToolInput = Static; +export type FindToolInput = z.infer; const DEFAULT_LIMIT = 1000; const GLOB_TIMEOUT_MS = 5000; @@ -107,7 +104,7 @@ export class FindTool implements AgentTool { async execute( _toolCallId: string, - params: Static, + params: z.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 c16e03f56..02567fd5c 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -3,9 +3,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; + import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { Settings } from "../config/settings"; import githubDescription from "../prompts/tools/github.md" with { type: "text" }; import * as git from "../utils/git"; @@ -197,135 +197,78 @@ const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]); const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]); const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]); -const githubSchema = Type.Object({ - op: StringEnum( - [ - "repo_view", - "pr_create", - "pr_checkout", - "pr_push", - "search_issues", - "search_prs", - "search_code", - "search_commits", - "search_repos", - "run_watch", - ], - { description: "github operation" }, - ), - repo: Type.Optional( - Type.String({ - description: "owner/repo (any op)", - examples: ["facebook/react"], - }), - ), - branch: Type.Optional( - Type.String({ - description: "branch (repo_view, pr_push local branch, run_watch)", - examples: ["main", "develop"], - }), - ), - pr: Type.Optional( - Type.Union( - [ - Type.String({ examples: ["123", "feature-branch"] }), - Type.Array(Type.String(), { - examples: [["123", "456"]], - }), - ], - { - description: - "pr number, url, or branch (pr_checkout); pass an array to batch-process multiple pull requests in one call", - }, - ), - ), - force: Type.Optional(Type.Boolean({ description: "reset existing local branch (pr_checkout)" })), - forceWithLease: Type.Optional(Type.Boolean({ description: "force-with-lease push (pr_push)" })), - title: Type.Optional( - Type.String({ - description: "PR title (pr_create)", - examples: ["Fix login bug"], - }), - ), - body: Type.Optional( - Type.String({ - description: "PR body markdown (pr_create); mutually exclusive with fill", - }), - ), - base: Type.Optional( - Type.String({ - description: "PR base branch (pr_create); defaults to repo default branch", - examples: ["main"], - }), - ), - head: Type.Optional( - Type.String({ - description: "PR head branch (pr_create); defaults to current branch", - examples: ["feature/foo"], - }), - ), - draft: Type.Optional(Type.Boolean({ description: "open PR as draft (pr_create)" })), - fill: Type.Optional( - Type.Boolean({ - description: "auto-fill PR title/body from commits (pr_create); mutually exclusive with title/body", - }), - ), - reviewer: Type.Optional( - Type.Array(Type.String(), { - description: "reviewers to request (pr_create); accepts users or org/team", - examples: [["octocat", "myorg/team"]], - }), - ), - assignee: Type.Optional( - Type.Array(Type.String(), { - description: "assignees (pr_create); use @me for the authenticated user", - examples: [["@me"]], - }), - ), - label: Type.Optional( - Type.Array(Type.String(), { - description: "labels to apply (pr_create)", - examples: [["bug", "enhancement"]], - }), - ), - query: Type.Optional( - Type.String({ - description: "search query (search_issues, search_prs, search_code, search_commits, search_repos)", - examples: ["is:open label:bug"], - }), - ), - since: Type.Optional( - Type.String({ - description: +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 (any op)").optional(), + branch: z.string().describe("branch (repo_view, pr_push local branch, run_watch)").optional(), + pr: z + .union([z.string(), z.array(z.string())]) + .describe( + "pr number, url, or branch (pr_checkout); pass an array to batch-process multiple pull requests in one call", + ) + .optional(), + force: z.boolean().describe("reset existing local branch (pr_checkout)").optional(), + forceWithLease: z.boolean().describe("force-with-lease push (pr_push)").optional(), + title: z.string().describe("PR title (pr_create)").optional(), + body: z.string().describe("PR body markdown (pr_create); mutually exclusive with fill").optional(), + base: z.string().describe("PR base branch (pr_create); defaults to repo default branch").optional(), + head: z.string().describe("PR head branch (pr_create); defaults to current branch").optional(), + draft: z.boolean().describe("open PR as draft (pr_create)").optional(), + fill: z + .boolean() + .describe("auto-fill PR title/body from commits (pr_create); mutually exclusive with title/body") + .optional(), + reviewer: z.array(z.string()).describe("reviewers to request (pr_create); accepts users or org/team").optional(), + assignee: z.array(z.string()).describe("assignees (pr_create); use @me for the authenticated user").optional(), + label: z.array(z.string()).describe("labels to apply (pr_create)").optional(), + query: z + .string() + .describe("search query (search_issues, search_prs, search_code, search_commits, search_repos)") + .optional(), + since: z + .string() + .describe( "lower-bound date for search_issues/search_prs/search_commits/search_repos. Accepts a relative duration (`` with unit `m`/`h`/`d`/`w`/`mo`/`y`, e.g. `3d`, `12h`, `2w`) or an ISO date (`YYYY-MM-DD`) / datetime. Translated to a `created:>=…` (or `committer-date:`/`pushed:`) qualifier; not supported by search_code.", - examples: ["3d", "2w", "2026-05-01"], - }), - ), - until: Type.Optional( - Type.String({ - description: + ) + .optional(), + until: z + .string() + .describe( "upper-bound date in the same format as `since`. With both, builds a `field:since..until` range qualifier.", - examples: ["1d", "2026-05-09"], - }), - ), - dateField: Type.Optional( - StringEnum(["created", "updated"], { - description: + ) + .optional(), + dateField: z + .enum(["created", "updated"] as const) + .describe( "date field used by `since`/`until`. issues/prs: `created` (default) or `updated`. repos: `created` (default) or `updated` (mapped to GitHub's `pushed:`). commits: ignored — always uses `committer-date`.", - default: "created", - }), - ), - limit: Type.Optional( - Type.Number({ - description: "max results (search_issues, search_prs, search_code, search_commits, search_repos)", - default: 10, - }), - ), - run: Type.Optional(Type.String({ description: "actions run id or url (run_watch)", examples: ["123456"] })), - tail: Type.Optional(Type.Number({ description: "log lines per failed job (run_watch)", default: 15 })), -}); + ) + .default("created") + .optional(), + limit: z + .number() + .default(10) + .describe("max results (search_issues, search_prs, search_code, search_commits, search_repos)") + .optional(), + run: z.string().describe("actions run id or url (run_watch)").optional(), + tail: z.number().default(15).describe("log lines per failed job (run_watch)").optional(), + }) + .strict(); -type GithubInput = Static; +type GithubInput = z.infer; export interface GhToolDetails { meta?: OutputMeta; diff --git a/packages/coding-agent/src/tools/hindsight-recall.ts b/packages/coding-agent/src/tools/hindsight-recall.ts index ca78b0573..856dd1f03 100644 --- a/packages/coding-agent/src/tools/hindsight-recall.ts +++ b/packages/coding-agent/src/tools/hindsight-recall.ts @@ -1,17 +1,15 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { formatCurrentTime, formatMemories } from "../hindsight/content"; import recallDescription from "../prompts/tools/recall.md" with { type: "text" }; import type { ToolSession } from "."; -const hindsightRecallSchema = Type.Object({ - query: Type.String({ - description: "Natural language search query. Be specific about what you need to know.", - }), +const hindsightRecallSchema = z.object({ + query: z.string().describe("Natural language search query. Be specific about what you need to know."), }); -export type HindsightRecallParams = Static; +export type HindsightRecallParams = z.infer; export class HindsightRecallTool implements AgentTool { readonly name = "recall"; diff --git a/packages/coding-agent/src/tools/hindsight-reflect.ts b/packages/coding-agent/src/tools/hindsight-reflect.ts index 8220c1a10..ba4b99f04 100644 --- a/packages/coding-agent/src/tools/hindsight-reflect.ts +++ b/packages/coding-agent/src/tools/hindsight-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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { ensureBankMission } from "../hindsight/bank"; import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" }; import type { ToolSession } from "."; -const hindsightReflectSchema = Type.Object({ - query: Type.String({ description: "The question to answer using long-term memory." }), - context: Type.Optional(Type.String({ description: "Optional additional context to guide the reflection." })), +const hindsightReflectSchema = z.object({ + query: z.string().describe("The question to answer using long-term memory."), + context: z.string().describe("Optional additional context to guide the reflection.").optional(), }); -export type HindsightReflectParams = Static; +export type HindsightReflectParams = z.infer; export class HindsightReflectTool implements AgentTool { readonly name = "reflect"; diff --git a/packages/coding-agent/src/tools/hindsight-retain.ts b/packages/coding-agent/src/tools/hindsight-retain.ts index 013db55a3..088a85edb 100644 --- a/packages/coding-agent/src/tools/hindsight-retain.ts +++ b/packages/coding-agent/src/tools/hindsight-retain.ts @@ -1,27 +1,25 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import retainDescription from "../prompts/tools/retain.md" with { type: "text" }; import type { ToolSession } from "."; -const hindsightRetainSchema = Type.Object({ - items: Type.Array( - Type.Object({ - content: Type.String({ - description: "The information to remember. Be specific and self-contained — include who, what, when, why.", +const hindsightRetainSchema = z.object({ + items: z + .array( + z.object({ + content: z + .string() + .describe("The information to remember. Be specific and self-contained — include who, what, when, why."), + context: z.string().describe("Optional context describing where this information came from.").optional(), }), - context: Type.Optional( - Type.String({ description: "Optional context describing where this information came from." }), - ), - }), - { - minItems: 1, - description: - "One or more memories to retain. Batch related facts in a single call rather than calling retain repeatedly — they are deduplicated and consolidated together.", - }, - ), + ) + .min(1) + .describe( + "One or more memories to retain. Batch related facts in a single call rather than calling retain repeatedly — they are deduplicated and consolidated together.", + ), }); -export type HindsightRetainParams = Static; +export type HindsightRetainParams = z.infer; export class HindsightRetainTool implements AgentTool { readonly name = "retain"; readonly label = "Retain"; diff --git a/packages/coding-agent/src/tools/image-gen.ts b/packages/coding-agent/src/tools/image-gen.ts index c85bbb25b..4afc973ca 100644 --- a/packages/coding-agent/src/tools/image-gen.ts +++ b/packages/coding-agent/src/tools/image-gen.ts @@ -1,6 +1,6 @@ import * as os from "node:os"; import * as path from "node:path"; -import { getAntigravityUserAgent, getEnvApiKey, type Model, StringEnum } from "@oh-my-pi/pi-ai"; +import { getAntigravityUserAgent, getEnvApiKey, type Model } from "@oh-my-pi/pi-ai"; import { CODEX_BASE_URL, getCodexAccountId, @@ -18,7 +18,7 @@ import { Snowflake, untilAborted, } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import packageJson from "../../package.json" with { type: "json" }; import { isAuthenticated, type ModelRegistry } from "../config/model-registry"; import type { CustomTool } from "../extensibility/custom-tools/types"; @@ -46,87 +46,37 @@ interface ImageApiKey { model?: Model; } -const responseModalitySchema = StringEnum(["IMAGE", "TEXT"]); -const aspectRatioSchema = StringEnum(["1:1", "3:4", "4:3", "9:16", "16:9"], { - description: "aspect ratio", - examples: ["1:1", "3:4", "16:9"], -}); -const imageSizeSchema = StringEnum(["1024x1024", "1536x1024", "1024x1536"], { - description: "image size", - examples: ["1024x1024", "1536x1024"], -}); +const responseModalitySchema = z.enum(["IMAGE", "TEXT"] as const); +const aspectRatioSchema = z.enum(["1:1", "3:4", "4:3", "9:16", "16:9"] as const).describe("aspect ratio"); +const imageSizeSchema = z.enum(["1024x1024", "1536x1024", "1024x1536"] as const).describe("image size"); -const inputImageSchema = Type.Object( - { - path: Type.Optional(Type.String({ description: "input image path" })), - data: Type.Optional(Type.String({ description: "base64 image data" })), - mime_type: Type.Optional(Type.String({ description: "mime type" })), - }, - { additionalProperties: false }, -); +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 baseImageSchema = Type.Object( - { - subject: Type.String({ - description: "main subject", - examples: ["a stoic robot barista", "a weathered lighthouse"], - }), - action: Type.Optional( - Type.String({ - description: "what subject is doing", - examples: ["pouring latte art", "standing against waves"], - }), - ), - scene: Type.Optional( - Type.String({ - description: "location or environment", - examples: ["futuristic café on mars", "thunderstorm at dusk"], - }), - ), - composition: Type.Optional( - Type.String({ - description: "camera angle and framing", - examples: ["low-angle close-up", "wide establishing shot"], - }), - ), - lighting: Type.Optional( - Type.String({ - description: "lighting setup", - examples: ["warm rim lighting", "golden hour backlight"], - }), - ), - style: Type.Optional( - Type.String({ - description: "artistic style", - examples: ["film noir", "studio ghibli watercolor", "photorealistic"], - }), - ), - text: Type.Optional( - Type.String({ - description: "text to render", - examples: ["headline 'urban explorer' top center"], - }), - ), - changes: Type.Optional( - Type.Array(Type.String(), { - description: "edits to make", - examples: [["change tie to green", "remove car"]], - }), - ), - aspect_ratio: Type.Optional(aspectRatioSchema), - image_size: Type.Optional(imageSizeSchema), - input: Type.Optional( - Type.Array(inputImageSchema, { - description: "input images", - }), - ), - }, - { additionalProperties: false }, -); +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(); export const imageGenSchema = baseImageSchema; -export type ImageGenParams = Static; -export type GeminiResponseModality = Static; +export type ImageGenParams = z.infer; +export type GeminiResponseModality = z.infer; /** * Assembles a structured prompt from the provided parameters. diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index 6c9ad87ae..a9aee3b71 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,7 +1,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { extractTextContent } from "../commit/utils"; import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver"; import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" }; @@ -15,15 +15,14 @@ import { import type { ToolSession } from "./index"; import { ToolError } from "./tool-errors"; -const inspectImageSchema = Type.Object( - { - path: Type.String({ description: "image path", examples: ["image.png"] }), - question: Type.String({ description: "question about image", examples: ["What is in this image?"] }), - }, - { additionalProperties: false }, -); +const inspectImageSchema = z + .object({ + path: z.string().describe("image path"), + question: z.string().describe("question about image"), + }) + .strict(); -export type InspectImageParams = Static; +export type InspectImageParams = z.infer; export interface InspectImageToolDetails { model: string; diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index 688f52e86..30d4a1bc9 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -20,39 +20,27 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import ircDescription from "../prompts/tools/irc.md" with { type: "text" }; import type { AgentRef, AgentRegistry } from "../registry/agent-registry"; import type { ToolSession } from "."; -const ircSchema = Type.Object({ - op: Type.Union( - [ - Type.Literal("send", { description: "Send a message to one peer or to all peers" }), - Type.Literal("list", { description: "List currently visible peers" }), - ], - { description: "IRC operation" }, - ), - to: Type.Optional( - Type.String({ - description: 'Recipient agent id (e.g. "0-Main", "0-AuthLoader") or "all" to broadcast', - examples: ["0-Main", "all"], - }), - ), - message: Type.Optional( - Type.String({ - description: "Message body to deliver", - examples: ["Should we use JWT or session cookies?"], - }), - ), - awaitReply: Type.Optional( - Type.Boolean({ - description: "Wait for the recipient's prose reply (default: true for DM, false for broadcast)", - }), - ), +const ircSchema = z.object({ + op: z + .union([ + z.literal("send").describe("Send a message to one peer or to all peers"), + z.literal("list").describe("List currently visible peers"), + ]) + .describe("IRC operation"), + to: z.string().optional().describe('Recipient agent id (e.g. "0-Main", "0-AuthLoader") or "all" to broadcast'), + message: z.string().optional().describe("Message body to deliver"), + awaitReply: z + .boolean() + .optional() + .describe("Wait for the recipient's prose reply (default: true for DM, false for broadcast)"), }); -type IrcParams = Static; +type IrcParams = z.infer; interface IrcReply { from: string; diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 19a52e144..9782b8684 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; @@ -22,28 +22,21 @@ import { } from "./render-utils"; import { ToolError } from "./tool-errors"; -const jobSchema = Type.Object({ - poll: Type.Optional( - Type.Array(Type.String(), { - description: "background job ids to wait for; omit (with no `cancel`) to wait on all running jobs", - examples: [["job-1234"]], - }), - ), - cancel: Type.Optional( - Type.Array(Type.String(), { - description: "background job ids to cancel", - examples: [["job-1234"]], - }), - ), - list: Type.Optional( - Type.Boolean({ - description: - "Return an immediate snapshot of every job spawned by this agent (running + completed within retention). Read-only \u2014 cannot be combined with `poll` or `cancel`.", - }), - ), +const jobSchema = z.object({ + poll: z + .array(z.string()) + .optional() + .describe("background job ids to wait for; omit (with no `cancel`) to wait on all running jobs"), + cancel: z.array(z.string()).optional().describe("background job ids to cancel"), + list: z + .boolean() + .optional() + .describe( + "Return an immediate snapshot of every job spawned by this agent (running + completed within retention). Read-only \u2014 cannot be combined with `poll` or `cancel`.", + ), }); -type JobParams = Static; +type JobParams = z.infer; const WAIT_DURATION_MS: Record = { "5s": 5_000, diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 687fba964..15a081c39 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -7,7 +7,7 @@ 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, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { getFileReadCache } from "../edit/file-read-cache"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -473,14 +473,13 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from: return text ? `${notice}\n${text}` : notice; } -const readSchema = Type.Object({ - path: Type.String({ - description: 'path or url; append : for line ranges or raw mode (e.g. "src/foo.ts:50-100")', - examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com/:1-40"], - }), -}); +const readSchema = z + .object({ + path: z.string().describe('path or url; append : for line ranges or raw mode (e.g. "src/foo.ts:50-100")'), + }) + .strict(); -export type ReadToolInput = Static; +export type ReadToolInput = z.infer; export interface ReadToolDetails { kind?: "file" | "url"; diff --git a/packages/coding-agent/src/tools/recipe/index.ts b/packages/coding-agent/src/tools/recipe/index.ts index 70332b04c..ed8eaafd3 100644 --- a/packages/coding-agent/src/tools/recipe/index.ts +++ b/packages/coding-agent/src/tools/recipe/index.ts @@ -1,7 +1,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme } from "../../modes/theme/theme"; import recipeDescription from "../../prompts/tools/recipe.md" with { type: "text" }; @@ -11,14 +11,12 @@ import { createRecipeToolRenderer, type RecipeRenderArgs } from "./render"; import { buildPromptModel, type DetectedRunner, resolveCommand } from "./runner"; import { RUNNERS } from "./runners"; -const recipeSchema = Type.Object({ - op: Type.String({ - description: 'task name and args, e.g. "test" or "build --release"', - examples: ["test", "build --release", "pkg:test --watch"], - }), -}); - -type RecipeParams = Static; +const recipeSchema = z + .object({ + op: z.string().describe('task name and args, e.g. "test" or "build --release"'), + }) + .strict(); +type RecipeParams = z.infer; type RecipeRenderResult = { content: Array<{ type: string; text?: string }>; diff --git a/packages/coding-agent/src/tools/render-mermaid.ts b/packages/coding-agent/src/tools/render-mermaid.ts index 38d89a883..d981efd27 100644 --- a/packages/coding-agent/src/tools/render-mermaid.ts +++ b/packages/coding-agent/src/tools/render-mermaid.ts @@ -1,22 +1,22 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { type MermaidAsciiRenderOptions, prompt, renderMermaidAscii } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" }; import type { ToolSession } from "./index"; -const renderMermaidSchema = Type.Object({ - mermaid: Type.String({ description: "mermaid source", examples: ["graph TD; A-->B"] }), - config: Type.Optional( - Type.Object({ - useAscii: Type.Optional(Type.Boolean()), - paddingX: Type.Optional(Type.Number()), - paddingY: Type.Optional(Type.Number()), - boxBorderPadding: Type.Optional(Type.Number()), - }), - ), +const renderMermaidSchema = z.object({ + mermaid: z.string().describe("mermaid source"), + config: z + .object({ + useAscii: z.boolean().optional(), + paddingX: z.number().optional(), + paddingY: z.number().optional(), + boxBorderPadding: z.number().optional(), + }) + .optional(), }); -type RenderMermaidParams = Static; +type RenderMermaidParams = z.infer; function sanitizeRenderConfig(config: MermaidAsciiRenderOptions | undefined): MermaidAsciiRenderOptions | undefined { if (!config) return undefined; diff --git a/packages/coding-agent/src/tools/report-tool-issue.ts b/packages/coding-agent/src/tools/report-tool-issue.ts index c5a7b1f97..2b0f26bf5 100644 --- a/packages/coding-agent/src/tools/report-tool-issue.ts +++ b/packages/coding-agent/src/tools/report-tool-issue.ts @@ -9,13 +9,13 @@ import { Database } from "bun:sqlite"; import path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { $flag, getAgentDir, logger, VERSION } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { Settings } from ".."; import type { ToolSession } from "./index"; -const ReportToolIssueParams = Type.Object({ - tool: Type.String({ description: "tool name", examples: ["bash", "read"] }), - report: Type.String({ description: "unexpected behavior" }), +const ReportToolIssueParams = z.object({ + tool: z.string().describe("tool name"), + report: z.string().describe("unexpected behavior"), }); export function isAutoQaEnabled(settings?: Settings): boolean { diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index d954fda60..f48b9f0c0 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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,18 +11,18 @@ import type { ToolSession } from "."; import { replaceTabs } from "./render-utils"; import { ToolError } from "./tool-errors"; -const resolveSchema = Type.Object({ - action: Type.Union([Type.Literal("apply"), Type.Literal("discard")]), - reason: Type.String({ description: "reason for action", examples: ["approved by user"] }), - extra: Type.Optional( - Type.Record(Type.String(), Type.Unknown(), { - description: - 'Free-form metadata interpreted by the resolving tool (e.g. plan-mode approval requires `{ title: "" }`).', - }), - ), +const resolveSchema = z.object({ + action: z.union([z.literal("apply"), z.literal("discard")]), + reason: z.string().describe("reason for action"), + extra: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Free-form metadata interpreted by the resolving tool (e.g. plan-mode approval requires `{ title: "" }`).', + ), }); -type ResolveParams = Static; +type ResolveParams = z.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 bca4361e8..75a3d0ad6 100644 --- a/packages/coding-agent/src/tools/review.ts +++ b/packages/coding-agent/src/tools/review.ts @@ -6,18 +6,15 @@ * Reviewers finish via `yield` tool with SubmitReviewDetails schema. */ // ───────────────────────────────────────────────────────────────────────────── -// Subprocess tool handlers - registered for extraction/rendering in task tool -// ───────────────────────────────────────────────────────────────────────────── + import path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { Theme, ThemeColor } from "../modes/theme/theme"; import { subprocessToolRegistry } from "../task/subprocess-tool-registry"; - export type FindingPriority = "P0" | "P1" | "P2" | "P3"; export interface FindingPriorityInfo { @@ -53,27 +50,18 @@ function getPriorityDisplay( } // report_finding schema -const ReportFindingParams = Type.Object({ - title: Type.String({ - description: "prefixed imperative title", - examples: ["[P1] un-padding wrong dimension"], - }), - body: Type.String({ - description: "problem explanation", - }), - priority: StringEnum(["P0", "P1", "P2", "P3"], { - description: "priority 0-3", - }), - confidence: Type.Number({ - minimum: 0, - maximum: 1, - description: "confidence score", - examples: [0.0, 0.5, 1.0], - }), - file_path: Type.String({ description: "file path" }), - line_start: Type.Number({ description: "start line" }), - line_end: Type.Number({ description: "end line" }), -}); +// 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(); 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 bbf735a04..25feebc46 100644 --- a/packages/coding-agent/src/tools/search-tool-bm25.ts +++ b/packages/coding-agent/src/tools/search-tool-bm25.ts @@ -1,7 +1,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type Component, Text } from "@oh-my-pi/pi-tui"; +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 Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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" }; @@ -30,15 +31,12 @@ const COLLAPSED_MATCH_LIMIT = 5; const MATCH_LABEL_LEN = 72; const MATCH_DESCRIPTION_LEN = 96; -const searchToolBm25Schema = Type.Object({ - query: Type.String({ - description: "tool search query", - examples: ["kubernetes pod", "image processing", "git commit"], - }), - limit: Type.Optional(Type.Integer({ description: "max matches", minimum: 1 })), +const searchToolBm25Schema = z.object({ + query: z.string().describe("tool search query"), + limit: z.number().int().min(1).optional().describe("max matches"), }); -type SearchToolBm25Params = Static; +type SearchToolBm25Params = z.infer; interface SearchToolBm25Match { name: string; diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index dbfd8ce65..4a90a242d 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -1,11 +1,10 @@ import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; - import { type GrepMatch, GrepOutputMode, type GrepResult, grep } 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 { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { getFileReadCache } from "../edit/file-read-cache"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; @@ -31,25 +30,23 @@ import { import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const searchSchema = Type.Object({ - pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }), - paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), { - minItems: 1, - description: "files, directories, globs, or internal URLs to search", - examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]], - }), - i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })), - gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })), - skip: Type.Optional( - Type.Number({ - description: - "files to skip before collecting results — use to paginate when the prior call hit the file limit", - default: 0, - }), - ), -}); +const searchSchema = z + .object({ + pattern: z.string().describe("regex 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"), + i: z.boolean().optional().describe("case-insensitive search"), + gitignore: z.boolean().optional().describe("respect gitignore"), + skip: z + .number() + .optional() + .describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"), + }) + .strict(); -export type SearchToolInput = Static; +export type SearchToolInput = z.infer; /** Maximum number of distinct files surfaced in a single response. The * agent paginates further pages via `skip`. */ @@ -89,7 +86,7 @@ export interface SearchToolDetails { missingPaths?: string[]; } -type SearchParams = Static; +type SearchParams = z.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 d4dcfa31e..5dcb8b389 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 { 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 Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { SSHHost } from "../capability/ssh"; import { sshCapability } from "../capability/ssh"; import { loadCapability } from "../discovery"; @@ -21,11 +21,11 @@ import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; -const sshSchema = Type.Object({ - host: Type.String({ description: "ssh host", examples: ["my-server", "prod-1"] }), - command: Type.String({ description: "remote command", examples: ["ls -la", "uptime"] }), - cwd: Type.Optional(Type.String({ description: "remote working directory", examples: ["/var/log"] })), - timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 60 })), +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), }); export interface SSHToolDetails { @@ -116,7 +116,7 @@ async function loadHosts(session: ToolSession): Promise<{ return { hostNames, hostsByName }; } -type SshToolParams = Static; +type SshToolParams = z.infer; export class SshTool implements AgentTool { readonly name = "ssh"; diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index fea7c3bb0..c909519d8 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -1,10 +1,9 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } 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 Static, Type } from "@sinclair/typebox"; import chalk from "chalk"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" }; @@ -45,45 +44,38 @@ export interface TodoWriteToolDetails { // Schema // ============================================================================= -const TodoOp = StringEnum(["init", "start", "done", "rm", "drop", "append", "note"] as const, { - description: "operation to apply", +const TodoOp = z + .enum(["init", "start", "done", "rm", "drop", "append", "note"] as const) + .describe("operation to apply"); + +const InitListEntry = z.object({ + phase: z.string().describe("phase name (short noun phrase)"), + items: z + .array(z.string().describe("task content (5-10 words)")) + .min(1) + .describe("tasks for this phase, in execution order; all start as pending"), }); -const InitListEntry = Type.Object({ - phase: Type.String({ description: "phase name (short noun phrase)", examples: ["Foundation", "Auth"] }), - items: Type.Array(Type.String({ description: "task content (5-10 words)" }), { - minItems: 1, - description: "tasks for this phase, in execution order; all start as pending", - }), -}); - -const TodoOpEntry = Type.Object({ +const TodoOpEntry = z.object({ op: TodoOp, - list: Type.Optional(Type.Array(InitListEntry, { description: "phased task list for op=init" })), - task: Type.Optional( - Type.String({ description: "task content for start/done/rm/drop/note", examples: ["Run tests"] }), - ), - phase: Type.Optional(Type.String({ description: "phase name for done/rm/drop/append", examples: ["Auth"] })), - items: Type.Optional( - Type.Array(Type.String({ description: "task content (5-10 words)" }), { - minItems: 1, - description: "tasks to append to `phase` for op=append", - }), - ), - text: Type.Optional(Type.String({ description: "note text for op=note (appended with newline)" })), + list: z.array(InitListEntry).optional().describe("phased task list for op=init"), + task: z.string().optional().describe("task content for start/done/rm/drop/note"), + phase: z.string().optional().describe("phase name for done/rm/drop/append"), + items: z + .array(z.string().describe("task content (5-10 words)")) + .min(1) + .optional() + .describe("tasks to append to `phase` for op=append"), + text: z.string().optional().describe("note text for op=note (appended with newline)"), }); -const todoWriteSchema = Type.Object( - { - ops: Type.Array(TodoOpEntry, { - minItems: 1, - description: "ordered todo operations", - }), - }, - { description: "Apply ordered todo operations" }, -); +const todoWriteSchema = z + .object({ + ops: z.array(TodoOpEntry).min(1).describe("ordered todo operations"), + }) + .describe("Apply ordered todo operations"); -type TodoWriteParams = Static; +type TodoWriteParams = z.infer; type TodoOpEntryValue = TodoWriteParams["ops"][number]; // ============================================================================= diff --git a/packages/coding-agent/src/tools/vim.ts b/packages/coding-agent/src/tools/vim.ts index 01ca213ee..862342b2f 100644 --- a/packages/coding-agent/src/tools/vim.ts +++ b/packages/coding-agent/src/tools/vim.ts @@ -2,8 +2,8 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { Component } from "@oh-my-pi/pi-tui"; import { extractSegments, sliceWithWidth, Text } from "@oh-my-pi/pi-tui"; import { isEnoent, logger, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; import * as Diff from "diff"; +import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp"; import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme"; @@ -36,35 +36,19 @@ import { toolResult } from "./tool-result"; const INTERNAL_URL_PREFIX = /^(agent|artifact|skill|rule|local|mcp):\/\//; const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); -const vimStepSchema = Type.Object({ - kbd: Type.Array(Type.String(), { - description: "vim key sequences", - examples: [["ggdGi"], ["3Go"], ["dd"]], - }), - insert: Type.Optional( - Type.String({ - description: "raw text to insert", - examples: ["hello world"], - }), - ), +const vimStepSchema = z.object({ + kbd: z.array(z.string()).describe("vim key sequences"), + insert: z.string().optional().describe("raw text to insert"), }); -const vimSchema = Type.Object({ - file: Type.String({ description: "file path", examples: ["src/foo.ts"] }), - steps: Type.Optional( - Type.Array(vimStepSchema, { - description: "editing steps", - }), - ), - pause: Type.Optional( - Type.Boolean({ - description: "skip auto-save", - }), - ), +const vimSchema = z.object({ + file: z.string().describe("file path"), + steps: z.array(vimStepSchema).optional().describe("editing steps"), + pause: z.boolean().optional().describe("skip auto-save"), }); -type VimParams = Static; -type VimStep = Static; +type VimParams = z.infer; +type VimStep = z.infer; interface VimRenderStep { kbd?: string[]; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index d2b280b2b..4e44358a4 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -5,7 +5,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 { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type Static, Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { stripHashlinePrefixes } from "../edit"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; @@ -58,12 +58,12 @@ async function loadFflate(): Promise { return fflateModulePromise; } -const writeSchema = Type.Object({ - path: Type.String({ description: "file path", examples: ["src/new.ts"] }), - content: Type.String({ description: "file content" }), +const writeSchema = z.object({ + path: z.string().describe("file path"), + content: z.string().describe("file content"), }); -export type WriteToolInput = Static; +export type WriteToolInput = z.infer; /** Details returned by the write tool for TUI rendering */ export interface WriteToolDetails { diff --git a/packages/coding-agent/src/tools/yield.ts b/packages/coding-agent/src/tools/yield.ts index 543d3e62c..d5cb85c3c 100644 --- a/packages/coding-agent/src/tools/yield.ts +++ b/packages/coding-agent/src/tools/yield.ts @@ -4,10 +4,10 @@ * Subagents must call this tool to finish and return structured JSON output. */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { dereferenceJsonSchema, sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema"; -import type { Static, TSchema } from "@sinclair/typebox"; -import { Type } from "@sinclair/typebox"; -import Ajv, { type ErrorObject, type ValidateFunction } from "ajv"; +import type { TSchema } from "@oh-my-pi/pi-ai/types"; +import { dereferenceJsonSchema, fromTypeBox, sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema"; +import type { ZodType } from "zod/v4"; +import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import { subprocessToolRegistry } from "../task/subprocess-tool-registry"; import type { ToolSession } from "."; import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema"; @@ -18,8 +18,6 @@ export interface YieldDetails { error?: string; } -const ajv = new Ajv({ allErrors: true, strict: false, logger: false }); - function formatSchema(schema: unknown): string { if (schema === undefined) return "No schema provided."; if (typeof schema === "string") return schema; @@ -30,16 +28,54 @@ function formatSchema(schema: unknown): string { } } -function formatAjvErrors(errors: ErrorObject[] | null | undefined): string { - if (!errors || errors.length === 0) return "Unknown schema validation error."; - return errors - .map(err => { - const path = err.instancePath ? `${err.instancePath}: ` : ""; - return `${path}${err.message ?? "invalid"}`; +function formatZodIssues(issues: ReadonlyArray | undefined): string { + if (!issues || issues.length === 0) return "Unknown schema validation error."; + return issues + .map(issue => { + const path = issue.path.length === 0 ? "" : `${issue.path.map(seg => String(seg)).join("/")}: `; + return `${path}${issue.message ?? "invalid"}`; }) .join("; "); } +function looseRecordSchema(description: string): Record { + return { + type: "object", + additionalProperties: true, + description, + }; +} + +function wrapYieldParameters(dataSchema: Record): Record { + return { + type: "object", + additionalProperties: false, + description: "submit data or error", + properties: { + result: { + anyOf: [ + { + type: "object", + additionalProperties: false, + description: "task succeeded", + properties: { data: dataSchema }, + required: ["data"], + }, + { + type: "object", + additionalProperties: false, + properties: { + error: { type: "string", description: "error message" }, + }, + required: ["error"], + }, + ], + }, + }, + required: ["result"], + }; +} + export class YieldTool implements AgentTool { readonly name = "yield"; readonly label = "Submit Result"; @@ -52,33 +88,15 @@ export class YieldTool implements AgentTool { readonly intent = "omit" as const; lenientArgValidation = true; - readonly #validate?: ValidateFunction; + readonly #validate?: ZodType; #schemaValidationFailures = 0; constructor(session: ToolSession) { - const createParameters = (dataSchema: TSchema): TSchema => - Type.Object( - { - result: Type.Union([ - Type.Object({ data: dataSchema }, { description: "task succeeded" }), - Type.Object({ - error: Type.String({ description: "error message" }), - }), - ]), - }, - { - additionalProperties: false, - description: "submit data or error", - }, - ) as TSchema; - - let validate: ValidateFunction | undefined; - let dataSchema: TSchema; + let validate: ZodType | undefined; let parameters: TSchema; try { const schemaResult = normalizeSchema(session.outputSchema); - // Convert JTD to JSON Schema if needed (auto-detected) const normalizedSchema = schemaResult.normalized !== undefined ? jtdToJsonSchema(schemaResult.normalized) : undefined; let schemaError = schemaResult.error; @@ -89,7 +107,7 @@ export class YieldTool implements AgentTool { if (normalizedSchema !== undefined && normalizedSchema !== false && !schemaError) { try { - validate = ajv.compile(normalizedSchema as Record | boolean); + validate = fromTypeBox(normalizedSchema as Record | boolean); } catch (err) { schemaError = err instanceof Error ? err.message : String(err); } @@ -109,27 +127,26 @@ export class YieldTool implements AgentTool { ? {} : undefined; + let dataSchema: Record; if (sanitizedSchema !== undefined) { const resolved = dereferenceJsonSchema({ ...sanitizedSchema, description: schemaDescription, - }); - dataSchema = Type.Unsafe(resolved as Record); + }) as Record; + dataSchema = resolved; } else { - dataSchema = Type.Record(Type.String(), Type.Any(), { - description: schemaError ? schemaDescription : "Structured JSON output (no schema specified)", - }); + dataSchema = looseRecordSchema( + schemaError ? schemaDescription : "Structured JSON output (no schema specified)", + ); } - parameters = createParameters(dataSchema); + parameters = wrapYieldParameters(dataSchema); JSON.stringify(parameters); - // Verify the final parameters compile with AJV (catches unresolved $ref, etc.) - ajv.compile(parameters as Record); + fromTypeBox(parameters as Record); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - dataSchema = Type.Record(Type.String(), Type.Any(), { - description: `Structured JSON output (schema processing failed: ${errorMsg})`, - }); - parameters = createParameters(dataSchema); + parameters = wrapYieldParameters( + looseRecordSchema(`Structured JSON output (schema processing failed: ${errorMsg})`), + ); validate = undefined; this.strict = false; } @@ -140,7 +157,7 @@ export class YieldTool implements AgentTool { async execute( _toolCallId: string, - params: Static, + params: unknown, _signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, @@ -170,12 +187,15 @@ export class YieldTool implements AgentTool { if (data === undefined || data === null) { throw new Error("data is required when yield indicates success"); } - if (this.#validate && !this.#validate(data)) { - this.#schemaValidationFailures++; - if (this.#schemaValidationFailures <= 1) { - throw new Error(`Output does not match schema: ${formatAjvErrors(this.#validate.errors)}`); + if (this.#validate) { + const parsed = this.#validate.safeParse(data); + if (!parsed.success) { + this.#schemaValidationFailures++; + if (this.#schemaValidationFailures <= 1) { + throw new Error(`Output does not match schema: ${formatZodIssues(parsed.error.issues)}`); + } + schemaValidationOverridden = true; } - schemaValidationOverridden = true; } } diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 8f73c8858..47b74cd1e 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -6,9 +6,8 @@ * */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme } from "../../modes/theme/theme"; import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" }; @@ -21,30 +20,16 @@ import type { SearchProviderId, SearchResponse } from "./types"; import { SearchProviderError } from "./types"; /** Web search tool parameters schema */ -export const webSearchSchema = Type.Object({ - query: Type.String({ description: "Search query" }), - recency: Type.Optional( - StringEnum(["day", "week", "month", "year"], { - description: "Recency filter (Brave, Perplexity)", - }), - ), - limit: Type.Optional(Type.Number({ description: "Max results to return" })), - max_tokens: Type.Optional(Type.Number({ description: "Maximum output tokens" })), - temperature: Type.Optional(Type.Number({ description: "Sampling temperature" })), - num_search_results: Type.Optional(Type.Number({ description: "Number of search results to retrieve" })), +export const webSearchSchema = z.object({ + query: z.string().describe("Search query"), + recency: z.enum(["day", "week", "month", "year"]).describe("Recency filter (Brave, Perplexity)").optional(), + limit: z.number().describe("Max results to return").optional(), + max_tokens: z.number().describe("Maximum output tokens").optional(), + temperature: z.number().describe("Sampling temperature").optional(), + num_search_results: z.number().describe("Number of search results to retrieve").optional(), }); -export type SearchToolParams = { - query: string; - recency?: "day" | "week" | "month" | "year"; - limit?: number; - /** Maximum output tokens. Defaults to 4096. */ - max_tokens?: number; - /** Sampling temperature (0–1). Lower = more focused/factual. Defaults to 0.2. */ - temperature?: number; - /** Number of search results to retrieve. Defaults to 10. */ - num_search_results?: number; -}; +export type SearchToolParams = z.infer; export interface SearchQueryParams extends SearchToolParams { provider?: SearchProviderId | "auto"; 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 d367d77a6..44cdfd74b 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 type { 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; // --------------------------------------------------------------------------- // Shared setup @@ -38,7 +38,7 @@ function makeFakeTool(name: string): AgentTool & { executeCalls: number } { name, label: name, description: `Fake ${name}`, - parameters: Type.Object({ command: Type.Optional(Type.String()) }), + parameters: z.object({ command: z.string().optional() }), 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 2a4266e90..1b0ca8f71 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -19,7 +19,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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { createAssistantMessage } from "./helpers/agent-session-setup"; // Mock stream that mimics AssistantMessageEventStream @@ -590,7 +590,7 @@ describe("AgentSession TTSR resume gate", () => { name: "mock_edit", label: "Mock Edit", description: "A mock edit tool", - parameters: Type.Object({}), + parameters: z.object({}), execute: async () => { await Bun.sleep(100); toolExecutionFinished = 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 bd4dac3b5..a040e9faf 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -12,7 +12,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 { TodoWriteTool } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { createAssistantMessage } from "./helpers/agent-session-setup"; type ObservedPromptCall = { @@ -121,7 +121,7 @@ describe("AgentSession eager todo enforcement", () => { name: "bash", label: "Bash", description: "Mock bash tool", - parameters: Type.Object({}), + parameters: z.object({}), 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 14fdfb7ae..4793a1b77 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; let tempDir: TempDir; let authStorage: AuthStorage | undefined; @@ -31,14 +31,14 @@ beforeEach(async () => { name: "bash", label: "Bash", description: "Mock bash tool", - parameters: Type.Object({}), + parameters: z.object({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), }; const writeTool: AgentTool = { name: "write", label: "Write", description: "Mock write tool", - parameters: Type.Object({}), + parameters: z.object({}), 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 55965a57d..0d770082e 100644 --- a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts +++ b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { Effort, type Model } from "@oh-my-pi/pi-ai"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { Settings } from "../src/config/settings"; import type { CustomTool } from "../src/extensibility/custom-tools/types"; import { AgentSession } from "../src/session/agent-session"; @@ -26,7 +26,7 @@ function createModel(): Model<"openai-responses"> { } function createBasicTool(name: string, label: string): AgentTool { - const schema = Type.Object({ value: Type.String() }); + const schema = z.object({ value: z.string() }); return { name, label, @@ -46,12 +46,12 @@ function createMcpTool( description: string, schemaKeys: string[], ): AgentTool { - const properties = Object.fromEntries(schemaKeys.map(key => [key, Type.String()])); + const properties = Object.fromEntries(schemaKeys.map(key => [key, z.string()])); return { name, label: `${serverName}/${mcpToolName}`, description, - parameters: Type.Object(properties), + parameters: z.object(properties), strict: true, mcpServerName: serverName, mcpToolName, @@ -68,12 +68,12 @@ function createMcpCustomTool( description: string, schemaKeys: string[], ): CustomTool { - const properties = Object.fromEntries(schemaKeys.map(key => [key, Type.String()])); + const properties = Object.fromEntries(schemaKeys.map(key => [key, z.string()])); return { name, label: `${serverName}/${mcpToolName}`, description, - parameters: Type.Object(properties), + parameters: z.object(properties), 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 4dad6d9a1..42b921a93 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 @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { Settings } from "../src/config/settings"; import type { CustomTool } from "../src/extensibility/custom-tools/types"; import { AgentSession } from "../src/session/agent-session"; @@ -32,7 +32,7 @@ function createBasicTool(name: string, label: string, description = `${label} to name, label, description, - parameters: Type.Object({ value: Type.String() }), + parameters: z.object({ value: z.string() }), strict: true, async execute() { return { content: [{ type: "text", text: `${name} executed` }] }; @@ -45,7 +45,7 @@ function createMcpCustomTool(name: string, serverName: string, mcpToolName: stri name, label: `${serverName}/${mcpToolName}`, description, - parameters: Type.Object({ q: Type.String() }), + parameters: z.object({ q: z.string() }), strict: true, mcpServerName: serverName, mcpToolName, diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 3c6dcdf13..334804656 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -24,7 +24,6 @@ import { tryRecoverHashlineWithCache, } from "@oh-my-pi/pi-coding-agent/edit"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { Value } from "@sinclair/typebox/value"; beforeAll(async () => { resetSettingsForTest(); @@ -556,11 +555,13 @@ describe("hashline executor", () => { describe("hashlineEditParamsSchema — extra-field tolerance", () => { it("accepts extra `path` field alongside `input`", () => { - expect(Value.Check(hashlineEditParamsSchema, { path: "x.ts", input: `@x.ts\n+ BOF\n${pl("x")}` })).toBe(true); + expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `@x.ts\n+ BOF\n${pl("x")}` }).success).toBe( + true, + ); }); it("still requires `input`", () => { - expect(Value.Check(hashlineEditParamsSchema, { path: "x.ts" })).toBe(false); + expect(hashlineEditParamsSchema.safeParse({ path: "x.ts" }).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 50a3652eb..3eeb2daa1 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -4,7 +4,7 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { disposeAllVmContexts } from "../../src/eval/js/context-manager"; import { executeJs, type JsResult } from "../../src/eval/js/executor"; @@ -16,7 +16,7 @@ function createTool( name, label: name, description: `${name} tool`, - parameters: Type.Object({}), + parameters: z.object({}), 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 db734d29e..811463622 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "bun:test"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; import { callSessionTool } from "../../src/eval/js/tool-bridge"; function createTool( @@ -13,7 +13,7 @@ function createTool( name, label: name, description: `${name} tool`, - parameters: Type.Object({}), + parameters: z.object({}), 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 065b1b15c..5ea79c2eb 100644 --- a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts +++ b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts @@ -29,7 +29,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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { return { @@ -64,11 +64,11 @@ function lastAssistantMessage(messages: Array<{ role: string }>): AssistantMessa } function buildMockEditTool(): AgentTool { - const schema = Type.Object({ - path: Type.String(), - diff: Type.String(), - op: Type.Optional(Type.String()), - rename: Type.Optional(Type.String()), + const schema = z.object({ + path: z.string(), + diff: z.string(), + op: z.string().optional(), + rename: z.string().optional(), }); return { name: "edit", @@ -311,7 +311,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 = Type.Object({ note: Type.Optional(Type.String()) }); + const schema = z.object({ note: z.string().optional() }); const errorTool: AgentTool = { name: "edit", label: "Edit", 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 1133601e3..166ca49c0 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 @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as typebox from "@sinclair/typebox"; +import * as z from "zod/v4"; import { GreenCommand } from "../../../src/extensibility/custom-commands/bundled/ci-green"; import type { CustomCommandAPI } from "../../../src/extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../src/extensibility/hooks/types"; @@ -19,7 +19,8 @@ function createApi(): CustomCommandAPI { code: 0, killed: false, }), - typebox, + typebox: {} as unknown as typeof import("../../../src/extensibility/typebox"), + zod: z, pi: piCodingAgent, }; } diff --git a/packages/coding-agent/test/sdk-mcp-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-discovery.test.ts index 0c2f2239a..395a9b449 100644 --- a/packages/coding-agent/test/sdk-mcp-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-discovery.test.ts @@ -9,7 +9,7 @@ import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom- import { createAgentSession } 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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; function createMcpCustomTool(name: string, serverName: string, mcpToolName: string): CustomTool { return { @@ -18,7 +18,7 @@ function createMcpCustomTool(name: string, serverName: string, mcpToolName: stri description: `Tool ${mcpToolName} from ${serverName}`, mcpServerName: serverName, mcpToolName, - parameters: Type.Object({ query: Type.String() }), + parameters: z.object({ query: z.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 e40a68461..8dff72279 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -8,14 +8,14 @@ import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-a 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 { Snowflake } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; 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: Type.Object({}), + parameters: z.object({}), defaultInactive: true, async execute() { return { content: [{ type: "text", text: "inactive" }] }; @@ -25,7 +25,7 @@ const toolActivationExtension: ExtensionFactory = pi => { name: "default_active_tool", label: "Default Active Tool", description: "Tool included in the initial active set.", - parameters: Type.Object({}), + parameters: z.object({}), async execute() { return { content: [{ type: "text", text: "active" }] }; }, diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 1333b43cc..76b7248e7 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -17,7 +17,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 { Type } from "@sinclair/typebox"; +import * as z from "zod/v4"; function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { return { @@ -114,11 +114,11 @@ async function createSession( } function buildEditTool(): AgentTool { - const schema = Type.Object({ - path: Type.String(), - diff: Type.String(), - op: Type.Optional(Type.String()), - rename: Type.Optional(Type.String()), + const schema = z.object({ + path: z.string(), + diff: z.string(), + op: z.string().optional(), + rename: z.string().optional(), }); return { diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index c5fe80ea8..deed5199b 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -5,8 +5,8 @@ import * as path from "node:path"; import { type AgentTool, INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; import { buildSystemPrompt, buildSystemPromptToolMetadata } from "@oh-my-pi/pi-coding-agent/system-prompt"; import { prompt } from "@oh-my-pi/pi-utils"; -import { Type } from "@sinclair/typebox"; import Handlebars from "handlebars"; +import * as z from "zod/v4"; const baseGitContext = { isRepo: true, @@ -320,7 +320,7 @@ describe("system Handlebars prompt templates", () => { name: "edit", label: "Edit", description: "Edits files", - parameters: Type.Object({}), + parameters: z.object({}), customWireName: "apply_patch", execute: async () => ({ content: [] }), } satisfies AgentTool; diff --git a/packages/coding-agent/test/task/executor-wall-clock.test.ts b/packages/coding-agent/test/task/executor-wall-clock.test.ts new file mode 100644 index 000000000..a525b473d --- /dev/null +++ b/packages/coding-agent/test/task/executor-wall-clock.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { ModelRegistry } from "../../src/config/model-registry"; +import { Settings } from "../../src/config/settings"; +import type { LoadExtensionsResult } from "../../src/extensibility/extensions/types"; +import type { CreateAgentSessionResult } from "../../src/sdk"; +import * as sdkModule from "../../src/sdk"; +import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/session/agent-session"; +import { runSubprocess } from "../../src/task/executor"; +import type { AgentDefinition } from "../../src/task/types"; +import { EventBus } from "../../src/utils/event-bus"; + +/** + * Contract: when `task.maxRuntimeMs` is set, a subagent whose inference call + * never resolves (provider stream hang the watchdog couldn't catch) MUST be + * aborted within ~maxRuntimeMs and surface a clear "runtime limit exceeded" + * reason — not a generic "Cancelled by caller" — so on-call engineers don't + * mistake it for a user cancellation. + * + * Without this defense, the executor's `await session.waitForIdle()` waits + * indefinitely (see session 019e2b4d-fa25-7000-a725-955278e9b293, subagent 7, + * which stayed silent for ~2 hours). + */ + +interface HangingSessionHandle { + session: AgentSession; + abortCalls: () => number; +} + +function createHangingSession(): HangingSessionHandle { + let abortCount = 0; + const { promise: hang, resolve: releaseHang } = Promise.withResolvers(); + const session: Partial = { + state: { messages: [] } as never, + agent: { state: { systemPrompt: ["test"] } } as never, + extensionRunner: undefined as never, + sessionManager: { + appendSessionInit: () => {}, + } as never, + getActiveToolNames: () => ["read", "yield"], + setActiveToolsByName: async (_names: string[]) => {}, + subscribe: (_listener: (event: AgentSessionEvent) => void) => () => {}, + prompt: async (_text: string, _options?: PromptOptions) => { + await hang; + }, + waitForIdle: async () => { + await hang; + }, + getLastAssistantMessage: () => undefined, + abort: async () => { + abortCount += 1; + releaseHang(); + }, + dispose: async () => {}, + }; + return { + session: session as AgentSession, + abortCalls: () => abortCount, + }; +} + +function mockCreateAgentSession(session: AgentSession) { + return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({ + session, + extensionsResult: {} as unknown as LoadExtensionsResult, + setToolUIContext: () => {}, + eventBus: new EventBus(), + } satisfies CreateAgentSessionResult); +} + +describe("runSubprocess wall clock (task.maxRuntimeMs)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const baseAgent: AgentDefinition = { + name: "task", + description: "test", + systemPrompt: "test", + source: "bundled", + }; + + const baseOptions = { + cwd: "/tmp", + agent: baseAgent, + task: "do work", + index: 0, + id: "subagent-walltime", + modelRegistry: { refresh: async () => {} } as unknown as ModelRegistry, + enableLsp: false, + }; + + it("aborts a stalled subagent and surfaces a runtime-limit reason", async () => { + const settings = Settings.isolated({ "task.maxRuntimeMs": 50 }); + const handle = createHangingSession(); + mockCreateAgentSession(handle.session); + + const startedAt = Date.now(); + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-timeout", + settings, + }); + const elapsedMs = Date.now() - startedAt; + + expect(result.aborted).toBe(true); + expect(result.exitCode).toBe(1); + expect(result.abortReason).toContain("runtime limit exceeded"); + expect(result.abortReason).toContain("task.maxRuntimeMs=50"); + expect(handle.abortCalls()).toBeGreaterThanOrEqual(1); + // Sanity: must finish in roughly the configured window (allow generous slack + // for CI; the contract is "doesn't hang for hours", not "exactly 50 ms"). + expect(elapsedMs).toBeLessThan(10_000); + }); + + it("does not abort early when the runtime budget is unlimited", async () => { + // Stub session resolves immediately to a no-op yield so we don't actually + // hang; we only need to assert that NO timeout fires when maxRuntimeMs=0. + const settings = Settings.isolated({ "task.maxRuntimeMs": 0 }); + const fastSession: Partial = { + state: { messages: [] } as never, + agent: { state: { systemPrompt: ["test"] } } as never, + extensionRunner: undefined as never, + sessionManager: { appendSessionInit: () => {} } as never, + getActiveToolNames: () => ["read", "yield"], + setActiveToolsByName: async () => {}, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + // Fire a synthetic yield on the next tick to drive runSubprocess to + // completion without depending on the real agent loop. + queueMicrotask(() => { + listener({ + type: "tool_execution_end", + toolCallId: "tool-fast", + toolName: "yield", + result: { + content: [{ type: "text", text: "Result submitted." }], + details: { status: "success", data: { ok: true } }, + }, + isError: false, + } as AgentSessionEvent); + }); + return () => {}; + }, + prompt: async () => {}, + waitForIdle: async () => {}, + getLastAssistantMessage: () => undefined, + abort: async () => {}, + dispose: async () => {}, + }; + mockCreateAgentSession(fastSession as AgentSession); + + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-no-limit", + settings, + }); + + expect(result.aborted).toBe(false); + expect(result.abortReason).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index 87ac0556c..4e1c05446 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { adaptSchemaForStrict } from "@oh-my-pi/pi-ai/utils/schema"; +import { adaptSchemaForStrict, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue"; import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; @@ -36,7 +36,7 @@ describe("ast_edit tool schema", () => { const tools = await createTools(createTestSession()); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); - const schema = asSchemaObject(tool?.parameters); + const schema = toolWireSchema(tool!); const properties = asSchemaObject(schema.properties); const ops = asSchemaObject(properties.ops); @@ -54,7 +54,7 @@ describe("ast_edit tool schema", () => { const tools = await createTools(createTestSession()); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); - const schema = asSchemaObject(tool?.parameters); + const schema = toolWireSchema(tool!); const strict = adaptSchemaForStrict(schema, true); expect(strict.strict).toBe(true); diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index 24e6d8d5c..e7c30222e 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -12,6 +12,7 @@ import { } from "@oh-my-pi/pi-coding-agent/tools/gh"; import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils"; +import * as z from "zod/v4"; function createSession( cwd: string = "/tmp/test", @@ -868,7 +869,8 @@ describe("github tool", () => { it("exposes a flat op-based schema without legacy run_watch parameters", () => { const tool = new GithubTool(createSession()); - const properties = tool.parameters.properties as Record; + const wire = z.toJSONSchema(tool.parameters, { target: "draft-7" }) as Record; + const properties = wire.properties as Record; expect(properties.op).toBeDefined(); expect(properties.interval).toBeUndefined(); expect(properties.grace).toBeUndefined(); diff --git a/packages/coding-agent/test/tools/inspect-image.test.ts b/packages/coding-agent/test/tools/inspect-image.test.ts index 479cd8889..ee7bbbe1a 100644 --- a/packages/coding-agent/test/tools/inspect-image.test.ts +++ b/packages/coding-agent/test/tools/inspect-image.test.ts @@ -10,7 +10,6 @@ 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-natives"; -import { Value } from "@sinclair/typebox/value"; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; @@ -200,8 +199,8 @@ describe("InspectImageTool", () => { it("schema rejects unknown parameters", () => { const tool = new InspectImageTool(createSession(testDir, visionModel)); expect(tool.strict).toBe(false); - expect(Value.Check(tool.parameters, { path: "img.png", question: "What is visible?" })).toBe(true); - expect(Value.Check(tool.parameters, { path: "img.png", question: "What is visible?", extra: "nope" })).toBe( + 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, ); }); diff --git a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts index 621aa5010..558eba703 100644 --- a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts +++ b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts @@ -5,6 +5,7 @@ import { type SchemaCompatibilityProvider, type SchemaCompatibilityResult, sanitizeSchemaForGoogle, + toolWireSchema, validateSchemaCompatibility, validateStrictSchemaEnforcement, } from "@oh-my-pi/pi-ai/utils/schema"; @@ -38,8 +39,8 @@ async function collectToolSchemas(): Promise { const byToolName = new Map>(); for (const tool of await createTools(session)) { - const schema = asSchemaObject(tool.parameters); - if (!schema) { + const schema = toolWireSchema(tool); + if (!asSchemaObject(schema)) { continue; } byToolName.set(tool.name, schema); @@ -50,8 +51,8 @@ async function collectToolSchemas(): Promise { if (!tool) { continue; } - const schema = asSchemaObject(tool.parameters); - if (!schema) { + const schema = toolWireSchema(tool); + if (!asSchemaObject(schema)) { continue; } byToolName.set(name, schema); diff --git a/packages/coding-agent/test/tools/resolve.test.ts b/packages/coding-agent/test/tools/resolve.test.ts index cca1b9013..ed755e3d4 100644 --- a/packages/coding-agent/test/tools/resolve.test.ts +++ b/packages/coding-agent/test/tools/resolve.test.ts @@ -4,6 +4,7 @@ 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-natives"; +import * as z from "zod/v4"; function createSession(handler?: (input: unknown) => Promise): ToolSession { return { @@ -23,8 +24,8 @@ function getText(result: { content: Array<{ type: string; text?: string }> }): s describe("ResolveTool", () => { it("requires action and reason in schema", () => { const tool = new ResolveTool(createSession()); - const schema = tool.parameters as { required?: string[] }; - expect(schema.required).toEqual(["action", "reason"]); + const wire = z.toJSONSchema(tool.parameters, { target: "draft-7" }) as { required?: string[] }; + expect(wire.required).toEqual(["action", "reason"]); }); it("errors when there is no pending action", async () => { diff --git a/packages/coding-agent/test/tools/task-simple-mode.test.ts b/packages/coding-agent/test/tools/task-simple-mode.test.ts index e00c8e50b..bde43b149 100644 --- a/packages/coding-agent/test/tools/task-simple-mode.test.ts +++ b/packages/coding-agent/test/tools/task-simple-mode.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { Settings } from "../../src/config/settings"; import { TaskTool } from "../../src/task"; import * as discoveryModule from "../../src/task/discovery"; @@ -25,7 +26,8 @@ function createSession(overrides: Partial> = {}): ToolSe } function getSchemaProperties(tool: TaskTool): Record { - return ((tool.parameters as { properties?: Record }).properties ?? {}) as Record; + const wire = toolWireSchema(tool) as { properties?: Record }; + return wire.properties ?? {}; } function getAssignmentDescription(tool: TaskTool): string {