Skip to content

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

Merged
kitlangton merged 2 commits intodevfrom
kit/tools-schema
Apr 23, 2026
Merged

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

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

@kitlangton kitlangton commented Apr 18, 2026

Note: this PR was previously titled "pin every tool's parameters schema before migration" and only contained the snapshot/parse safety net. The stacked migration (originally #23293) was merged into this branch and rebased against current dev, so this single PR now lands both the safety net and the full framework+tools migration.

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 elsewhere and 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 share one implementation:

  • session/prompt.ts — every LLM tool registration
  • server/routes/instance/experimental.ts — was previously calling z.toJSONSchema(t.parameters) directly; would have failed at runtime once t.parameters became a Schema. Fixed here.
  • test/tool/parameters.test.ts — snapshot test mirrors the production path so any future drift fails the snapshot.

Tools (17 files — multiedit was deleted by #23701 on dev)

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. LLM tool-call path receives typed JSON; string coercion was only relevant to CLI callers, of which there are none. JSON Schema output identical.
  • 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 unchanged.

Safety net (originally PR #23244)

  • Every tool exports its Parameters schema at module scope.
  • 17 JSON Schema snapshots + per-tool parse accepts/rejects assertions in test/tool/parameters.test.ts. These caught zero drift during the migration.

Rebase notes

Rebased onto current dev (was ~18 days old). Conflicts resolved:

  • multiedit was deleted on dev by chore: kill unused tool #23701; dropped from both commits.
  • edit.ts — kept HEAD's locks/lock() helper added on dev plus branch's Schema migration.
  • read.ts — kept HEAD's new readSample/isBinaryFile helpers plus branch's Schema migration.
  • skill.ts — kept HEAD's refactored execute body (no more nested return { description, parameters, execute } wrapper) plus branch's Schema migration.
  • todo.ts — kept branch's inlined Schema (matches HEAD's zod-inlined pattern semantically; same LLM-visible JSON Schema).
  • util/effect-zod.ts — merged dev's zodObject helper with branch's toJsonSchema helper.

Verification

  • bun typecheck clean
  • bun run test — 2123 pass / 0 fail (full suite)
  • 17 JSON Schema snapshots byte-identical — LLM view of every tool unchanged.
  • SDK byte-identical (types.gen.ts and openapi.json)

Scope notes

  • Tool authors now write pure Schema. No zod in any tool file.
  • The walker (@/util/effect-zod) and central EffectZod.toJsonSchema helper remain — they convert Schema → zod at the edge where ai SDK wants zod-style JSON Schema. Retirable when the runtime LLM layer accepts Effect Schema natively.
  • session/todo.ts still exports a zod Info; migration deferred (the tool's todowrite Parameters inlines the fields rather than depending on it).

Pre-migration safety net for the upcoming tool-by-tool zod\u2192Schema
conversion. Every tool's parameters schema now has:

1. A JSON Schema snapshot (`z.toJSONSchema` with `io: "input"`) \u2014 this
   captures exactly what the LLM sees at tool registration time, so any
   drift caused by a future migration fails the snapshot.
2. Parse-accept/parse-reject assertions per tool pinning the
   user-visible behavioural contract (required fields, refinement
   bounds, enum membership, default values).

To make the snapshots possible without standing up each tool's full
Effect runtime, every tool file now exports its parameters schema as
`Parameters` at module scope:

- 9 tools already had a module-level const \u2014 just added `export`, and
  standardised the name to `Parameters` (uppercase) where it was
  previously `parameters`.
- 9 tools had their schema inline inside `Tool.define` \u2014 hoisted to
  module scope under the same `Parameters` name and wired back through.

Zero behaviour change: Tool.define still sees the same schema, runtime
validation path is identical, SDK (types.gen.ts + openapi.json) is
byte-identical, and the full 2054-test suite passes.

18 JSON Schema snapshots and 43 explicit parse/reject assertions for the
18 built-in tools (apply_patch, bash, codesearch, edit, glob, grep,
invalid, lsp, multiedit, plan, question, read, skill, task, todo,
webfetch, websearch, write).
@kitlangton kitlangton changed the title test(tool): pin every tool's parameters schema before migration refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema Apr 23, 2026
@kitlangton kitlangton disabled auto-merge April 23, 2026 20:09
@kitlangton kitlangton merged commit 3910a6e into dev Apr 23, 2026
9 checks passed
@kitlangton kitlangton deleted the kit/tools-schema branch April 23, 2026 20:09
TTK95 pushed a commit to TTK95/opencode that referenced this pull request Apr 24, 2026
Conflict resolutions:
- packages/{opencode,plugin,web}/package.json: bump to 1.14.22-dev_ttk
- tool/bash.ts: port run_in_background param from zod to Effect Schema
  (upstream refactored tool params to Schema.Struct in anomalyco#23244)
- tool/task.ts: port isolation=worktree param from zod to Effect Schema,
  keep worktree helper imports (path/os/fs/spawn/Instance/Log)
TTK95 pushed a commit to TTK95/opencode that referenced this pull request Apr 24, 2026
…Schema

Matches the Tool.define schema migration in upstream anomalyco#23244. Without this
the tool registry can't construct these tools and runtime errors out with
"undefined is not an object (evaluating 'X.context')" on every prompt.
xywsxp pushed a commit to xywsxp/opencode that referenced this pull request Apr 24, 2026
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