refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema#23293
Merged
kitlangton merged 2 commits intokit/tools-schemafrom Apr 18, 2026
Merged
refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema#23293kitlangton merged 2 commits intokit/tools-schemafrom
kitlangton merged 2 commits intokit/tools-schemafrom
Conversation
…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
added a commit
that referenced
this pull request
Apr 23, 2026
kitlangton
added a commit
that referenced
this pull request
Apr 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #23244. Fully removes zod from the tool layer.
Framework (
tool/tool.ts)Tool.Def.parameterstyped asSchema.Decoder<unknown>(replacesz.ZodType).Schema.decodeUnknownEffect+Effect.mapErrorinstead ofEffect.trywrappingtoolInfo.parameters.parse(args)(matches idiomatic patterns already used inmcp-exa.tsandinstallation/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)intoutil/effect-zod.ts. Three emit sites now share one implementation:session/prompt.ts:407— every LLM tool registrationserver/routes/instance/experimental.ts:216— was previously callingz.toJSONSchema(t.parameters)directly; would have failed at runtime oncet.parametersbecame 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: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)Schema.withDecodingDefault(Effect.succeed(5000))z.infer<typeof Parameters>Schema.Schema.Type<typeof Parameters>Notable simplifications:
read.ts: droppedz.coerce.number()onoffset/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 pullingTodo.Info.shapefrom the still-zodsession/todo.ts. Removes the last zod import from the tool file; LLM-visible JSON Schema is identical.question.ts: referencesQuestion.Prompt(already aSchema.Class) directly instead ofQuestion.Prompt.zod.Plugin tools (
tool/registry.ts)Plugin tool args are defined via zod (
@opencode-ai/pluginAPI — external). The registry wraps the derivedz.object(def.args)in aSchema.declare(typeGuard)so it slots into the Schema-typed framework, and annotates it withZodOverrideso 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
bun typecheckcleanbun run test test/— 2054 pass / 0 fail (full suite)types.gen.tsandopenapi.json)Scope notes
@/util/effect-zod) and the centralEffectZod.toJsonSchemahelper remain — they convert Schema → zod at the edge where theaiSDK 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.tsstill exports a zodInfo; migration deferred to a follow-up (not touching it here keeps this PR scoped to the tool layer).