Skip to content

refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema#23293

Merged
kitlangton merged 2 commits intokit/tools-schemafrom
kit/tools-schema-all
Apr 18, 2026
Merged

refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema#23293
kitlangton merged 2 commits intokit/tools-schemafrom
kit/tools-schema-all

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

Stacked on #23244. Fully removes zod from the tool layer.

Framework (tool/tool.ts)

  • Tool.Def.parameters typed as Schema.Decoder<unknown> (replaces z.ZodType).
  • Runtime validation uses Schema.decodeUnknownEffect + Effect.mapError instead of Effect.try wrapping toolInfo.parameters.parse(args) (matches idiomatic patterns already used in mcp-exa.ts and installation/index.ts; satisfies AGENTS.md's avoid-try/catch rule).
  • formatValidationError?(error: unknown) — the hook is no longer zod-specific. No tool currently implements it.

JSON Schema pipeline

Extracted EffectZod.toJsonSchema(schema) into util/effect-zod.ts. Three emit sites now share one implementation:

  • session/prompt.ts:407 — every LLM tool registration
  • server/routes/instance/experimental.ts:216 — was previously calling z.toJSONSchema(t.parameters) directly; would have failed at runtime once t.parameters became a Schema. Caught by the review pass, fixed by this PR.
  • test/tool/parameters.test.ts — snapshot test mirrors the production path, so any future drift is impossible.

Tools (18 files)

Mechanical conversion of each tool's Parameters:

Before (zod) After (Schema)
z.object({...}) Schema.Struct({...})
z.string().describe("x") Schema.String.annotate({ description: "x" })
z.string().optional() Schema.optional(Schema.String)
z.number().int().min(1) Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(1))
z.enum([...]) Schema.Literals([...])
z.array(X) Schema.mutable(Schema.Array(X))
z.number().min(1000).max(50000).default(5000) chained checks + Schema.withDecodingDefault(Effect.succeed(5000))
z.infer<typeof Parameters> Schema.Schema.Type<typeof Parameters>

Notable simplifications:

  • read.ts: dropped z.coerce.number() on offset/limit. The LLM tool-call path receives typed JSON; coercion from strings is only relevant to CLI-facing callers, of which there are none. JSON Schema output is identical (type: "number").
  • todo.ts: inlined the three todo fields rather than pulling Todo.Info.shape from the still-zod session/todo.ts. Removes the last zod import from the tool file; LLM-visible JSON Schema is identical.
  • question.ts: references Question.Prompt (already a Schema.Class) directly instead of Question.Prompt.zod.

Plugin tools (tool/registry.ts)

Plugin tool args are defined via zod (@opencode-ai/plugin API — external). The registry wraps the derived z.object(def.args) in a Schema.declare(typeGuard) so it slots into the Schema-typed framework, and annotates it with ZodOverride so the walker emits the original zod object when generating JSON Schema for the LLM. Runtime behaviour and JSON Schema for plugin tools are unchanged.

Verification

Scope notes

  • Tool authors write pure Schema. No zod in any tool file.
  • The walker (@/util/effect-zod) and the central EffectZod.toJsonSchema helper remain — they convert Schema → zod at the edge where the ai SDK still wants zod-style JSON Schema. These go away entirely when the runtime LLM layer eventually accepts Effect Schema natively (orthogonal future work).
  • session/todo.ts still exports a zod Info; migration deferred to a follow-up (not touching it here keeps this PR scoped to the tool layer).

…ect Schema

Removes zod from the tool layer. Each tool's `Parameters` is now a
`Schema.Struct({...})`, and the framework accepts `Schema.Decoder<unknown>`
instead of `z.ZodType`.

## Framework (tool/tool.ts)

- `Tool.Def.parameters` typed as `Schema.Decoder<unknown>`.
- Runtime validation uses `Schema.decodeUnknownEffect` + `Effect.mapError`
  (replaces the `Effect.try` wrapping `toolInfo.parameters.parse(args)`).
- `formatValidationError?(error: unknown)` \u2014 the hook is no longer zod-specific
  (no tool currently implements it).

## JSON Schema pipeline

Extracted `EffectZod.toJsonSchema(schema)` into `util/effect-zod.ts` so the
three emit sites stay in lockstep:

- `session/prompt.ts:407` (every LLM tool registration)
- `server/routes/instance/experimental.ts:216` (was previously calling
  `z.toJSONSchema(t.parameters)` directly \u2014 would fail at runtime once
  `t.parameters` became a Schema)
- `test/tool/parameters.test.ts` (snapshot test mirrors the production path)

## Tools (18 files)

Mechanical conversion of each tool's `Parameters`:

- `z.object({...})` \u2192 `Schema.Struct({...})`
- `z.string().describe("x")` \u2192 `Schema.String.annotate({ description: "x" })`
- `z.string().optional()` \u2192 `Schema.optional(Schema.String)`
- `z.number().int().min(1)` \u2192 `Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(1))`
- `z.enum([...])` \u2192 `Schema.Literals([...])`
- `z.array(X)` \u2192 `Schema.mutable(Schema.Array(X))`
- `z.number().min(1000).max(50000).default(5000)` \u2192 chained checks + `Schema.withDecodingDefault(Effect.succeed(5000))`
- `z.infer<typeof Parameters>` \u2192 `Schema.Schema.Type<typeof Parameters>`

Notable simplifications:

- `read.ts`: dropped `z.coerce.number()` on `offset`/`limit`. The LLM tool-
  call path receives typed JSON; coercion from strings is only relevant to
  CLI-facing callers, of which there are none. JSON Schema output is
  identical (`type: "number"`).
- `todo.ts`: inlined the three todo fields rather than pulling `Todo.Info.shape`
  from the still-zod `session/todo.ts`. Removes the last zod import from the
  tool file; LLM-visible JSON Schema is identical.
- `question.ts`: references `Question.Prompt` (already a `Schema.Class`)
  directly instead of `Question.Prompt.zod`.

## Plugin tools (tool/registry.ts)

Plugin tool args are still defined with zod (`@opencode-ai/plugin` API). The
registry wraps the derived zod object in a `Schema.declare` so it slots into
the Schema-typed framework, and annotates it with `ZodOverride` so the walker
emits the original zod object when generating JSON Schema for the LLM.

## Test updates

- `test/tool/parameters.test.ts`: helpers use `Schema.decodeUnknownSync` and
  `Result.isSuccess(Schema.decodeUnknownResult(...))` (avoids try/catch per
  AGENTS.md). JSON Schema snapshots are emitted through the shared
  `EffectZod.toJsonSchema` helper, so any future drift between the test and
  prompt.ts emission is impossible.
- `test/tool/tool-define.test.ts`: swapped `z.object(...)` for `Schema.Struct(...)`.

## Verification

- `bun typecheck` clean
- `bun run test test/` \u2014 2054 pass / 0 fail (full suite)
- 18 JSON Schema snapshots byte-identical \u2014 LLM view of every tool is
  unchanged.
- SDK byte-identical (`types.gen.ts` and `openapi.json` unchanged).
@kitlangton kitlangton merged commit a49b5ad into kit/tools-schema Apr 18, 2026
6 of 7 checks passed
@kitlangton kitlangton deleted the kit/tools-schema-all branch April 18, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant