diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 0e5f24e677c..b159e0fc400 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -35,15 +35,15 @@ export const DEFAULT_SEARCH_LIMIT = 100 export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ pattern: Schema.String, - path: RelativePath.pipe(Schema.optional), - limit: PositiveInt.pipe(Schema.optional), + path: Schema.optionalKey(RelativePath), + limit: Schema.optionalKey(PositiveInt), }) {} export class GrepInput extends Schema.Class("FileSystem.GrepInput")({ pattern: Schema.String, - path: RelativePath.pipe(Schema.optional), - include: Schema.String.pipe(Schema.optional), - limit: PositiveInt.pipe(Schema.optional), + path: Schema.optionalKey(RelativePath), + include: Schema.optionalKey(Schema.String), + limit: Schema.optionalKey(PositiveInt), }) {} export const Event = FileSystem.Event diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 3807020ab24..93867fa5f95 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -25,7 +25,7 @@ export const Input = Schema.Struct({ }), oldString: Schema.String.annotate({ description: "Exact text to replace" }), newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }), - replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({ + replaceAll: Schema.optionalKey(Schema.Boolean).annotate({ description: "Replace all exact occurrences of oldString (default false)", }), }) diff --git a/packages/core/src/tool/plugin/glob.ts b/packages/core/src/tool/plugin/glob.ts index f1003ef5566..0f75bf92d4f 100644 --- a/packages/core/src/tool/plugin/glob.ts +++ b/packages/core/src/tool/plugin/glob.ts @@ -16,7 +16,7 @@ export const name = "glob" export const Input = Schema.Struct({ pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }), - path: RelativePath.pipe(Schema.optional).annotate({ + path: Schema.optionalKey(RelativePath).annotate({ description: "Directory to search. Defaults to the current working directory.", }), limit: FileSystem.GlobInput.fields.limit.annotate({ diff --git a/packages/core/src/tool/plugin/grep.ts b/packages/core/src/tool/plugin/grep.ts index 64dcf45fe79..46e0de04a6d 100644 --- a/packages/core/src/tool/plugin/grep.ts +++ b/packages/core/src/tool/plugin/grep.ts @@ -20,7 +20,7 @@ export const Input = Schema.Struct({ ).annotate({ description: "Regular expression to search for in file contents (ripgrep syntax)", }), - path: RelativePath.pipe(Schema.optional).annotate({ + path: Schema.optionalKey(RelativePath).annotate({ description: "File or directory to search. Defaults to the current working directory.", }), include: FileSystem.GrepInput.fields.include.annotate({ diff --git a/packages/core/src/tool/plugin/read.ts b/packages/core/src/tool/plugin/read.ts index eb14296c34e..5ff8bee77f5 100644 --- a/packages/core/src/tool/plugin/read.ts +++ b/packages/core/src/tool/plugin/read.ts @@ -24,7 +24,7 @@ const LocationInput = Schema.Struct({ description: "The maximum number of lines or directory entries to read (defaults to 2000)", }), }) -const Input = LocationInput +export const Input = LocationInput const Output = Schema.Union([ ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index f26224caeab..ec177e90068 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -24,32 +24,31 @@ const BACKGROUND_INSTRUCTION = export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), - workdir: Schema.String.pipe(Schema.optional).annotate({ + workdir: Schema.optionalKey(Schema.String).annotate({ description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", }), - timeout: NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) - .pipe(Schema.optional) + timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))) .annotate({ description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`, }), - background: Schema.Boolean.pipe(Schema.optional).annotate({ + background: Schema.optionalKey(Schema.Boolean).annotate({ description: "Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.", }), }) const StructuredOutput = Schema.Struct({ - exit: Schema.Number.pipe(Schema.optional), - shellID: Schema.String.pipe(Schema.optional), + exit: Schema.optionalKey(Schema.Number), + shellID: Schema.optionalKey(Schema.String), truncated: Schema.Boolean, - timeout: Schema.Boolean.pipe(Schema.optional), + timeout: Schema.optionalKey(Schema.Boolean), }) const Output = Schema.Struct({ ...StructuredOutput.fields, output: Schema.String, - status: Schema.Literals(["completed", "running"]).pipe(Schema.optional), - warnings: Schema.Array(Schema.String).pipe(Schema.optional), + status: Schema.optionalKey(Schema.Literals(["completed", "running"])), + warnings: Schema.optionalKey(Schema.Array(Schema.String)), }) type Output = typeof Output.Type diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index ee7f54769a0..b7d5facb186 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -19,7 +19,7 @@ export const Input = Schema.Struct({ agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }), description: Schema.String.annotate({ description: "A short description of the subagent's task" }), prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), - background: Schema.Boolean.pipe(Schema.optional).annotate({ + background: Schema.optionalKey(Schema.Boolean).annotate({ description: "Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.", }), diff --git a/packages/core/src/tool/plugin/webfetch.ts b/packages/core/src/tool/plugin/webfetch.ts index b4298cfbc54..b42cdd718ce 100644 --- a/packages/core/src/tool/plugin/webfetch.ts +++ b/packages/core/src/tool/plugin/webfetch.ts @@ -18,14 +18,14 @@ export const description = `Fetch content from an HTTP or HTTPS URL and return i Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.` -const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS)) +const Timeout = Schema.Finite.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS)) export const Input = Schema.Struct({ url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) .annotate({ description: "The format to return the content in. Defaults to markdown." }) - .pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))), - timeout: Timeout.pipe(Schema.optional).annotate({ + .pipe(Schema.withDecodingDefaultKey(Effect.succeed("markdown" as const))), + timeout: Schema.optionalKey(Timeout).annotate({ description: `Optional timeout in seconds (maximum: ${MAX_TIMEOUT_SECONDS})`, }), }) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index bafecd706e9..3b7a3fed732 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -6,7 +6,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { AbsolutePath, PositiveInt, RelativePath } from "../schema" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema" export const MAX_READ_LINES = 2_000 export const MAX_READ_BYTES = 50 * 1024 @@ -70,8 +70,8 @@ export type ReadError = | PathKindError export const PageInput = Schema.Struct({ - offset: PositiveInt.pipe(Schema.optional), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), + offset: Schema.optionalKey(NonNegativeInt), + limit: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES))), }) export type PageInput = typeof PageInput.Type @@ -87,14 +87,14 @@ export class TextPage extends Schema.Class("ReadTool.TextPage")({ mime: Schema.String, offset: PositiveInt, truncated: Schema.Boolean, - next: PositiveInt.pipe(Schema.optional), + next: Schema.optionalKey(PositiveInt), }) {} export class ListPage extends Schema.Class("ReadTool.ListPage")({ type: Schema.Literal("list-page"), entries: Schema.Array(FileSystem.Entry), truncated: Schema.Boolean, - next: PositiveInt.pipe(Schema.optional), + next: Schema.optionalKey(PositiveInt), }) {} export interface Interface { @@ -240,8 +240,8 @@ export const read = Effect.fn("ReadTool.read")(function* ( mime: FSUtil.mimeType(real), } } - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) + const offset = page.offset || 1 + const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES) const lines: string[] = [] const decoder = new TextDecoder("utf-8", { fatal: true }) let pending = "" @@ -334,8 +334,8 @@ export const read = Effect.fn("ReadTool.read")(function* ( export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { const real = yield* fs.realPath(input) const items = yield* fs.readDirectoryEntries(real) - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) + const offset = page.offset || 1 + const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES) const entries = yield* Effect.forEach( items, (item) => diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index 5c5b767c8c8..c2aad5e2e20 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -100,8 +100,89 @@ const outputJsonSchema = (schema: Tool.ValueSchema): JsonSchema.JsonSchema const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { const document = Schema.toJsonSchemaDocument(schema) - if (Object.keys(document.definitions).length === 0) return document.schema - return { ...document.schema, $defs: document.definitions } + // Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it + // without changing validation: `{ type: "integer", allOf: [{ minimum: 0 }] }` becomes + // `{ type: "integer", minimum: 0 }` only when no keyword would be overwritten. Named schemas + // emit `$ref` plus root `$defs`; inline acyclic local references so providers receive the full + // nested schema directly, then remove unused `$defs`. Recursive references stay intact because + // expanding them would never terminate. + const normalized = flattenAllOf( + Object.keys(document.definitions).length === 0 + ? document.schema + : { ...document.schema, $defs: document.definitions }, + ) + return dropDefinitionsIfResolved(inlineLocalReferences(normalized)) as JsonSchema.JsonSchema +} + +const flattenAllOf = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(flattenAllOf) + if (typeof value !== "object" || value === null) return value + + const schema = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, flattenAllOf(item)])) + if (!Array.isArray(schema.allOf) || !schema.allOf.every(isRecord) || !canFlattenAllOf(schema.allOf, schema)) + return schema + const { allOf, ...rest } = schema + return flattenAllOf({ ...Object.assign({}, ...allOf), ...rest }) +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const canFlattenAllOf = (allOf: ReadonlyArray>, parent: Record) => { + const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf")) + return allOf.every((item) => + Object.keys(item).every((key) => { + if (keys.has(key)) return false + keys.add(key) + return true + }), + ) +} + +const inlineLocalReferences = ( + value: unknown, + definitions?: Record, + seen = new Set(), +): unknown => { + if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen)) + if (!isRecord(value)) return value + + const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined) + if (typeof value.$ref === "string" && localDefinitions) { + const segment = value.$ref.match(/^#\/\$defs\/([^/]+)$/)?.[1] + const name = segment?.replaceAll("~1", "/").replaceAll("~0", "~") + if (name && !seen.has(name)) { + const target = localDefinitions[name] + if (target) { + const { $ref: _, ...rest } = value + const resolvedTarget = inlineLocalReferences(target, localDefinitions, new Set(seen).add(name)) + const resolvedSiblings = inlineLocalReferences(rest, localDefinitions, seen) + if (!isRecord(resolvedTarget) || !isRecord(resolvedSiblings)) return resolvedTarget + if (canMergeRecords(resolvedTarget, resolvedSiblings)) return { ...resolvedTarget, ...resolvedSiblings } + return { allOf: [resolvedTarget, resolvedSiblings] } + } + } + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]), + ) +} + +const canMergeRecords = (left: Record, right: Record) => + Object.keys(left).every((key) => !(key in right)) + +const dropDefinitionsIfResolved = (value: unknown): unknown => { + if (!isRecord(value) || hasLocalReference(value)) return value + const { $defs: _, ...rest } = value + return rest +} + +const hasLocalReference = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(hasLocalReference) + if (!isRecord(value)) return false + if (typeof value.$ref === "string" && value.$ref.startsWith("#/$defs/")) return true + return Object.values(value).some(hasLocalReference) } export const normalizeContent = (value: string | ReadonlyArray | undefined, output?: unknown) => { diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts index 4008e0a8c44..7f3e13e685a 100644 --- a/packages/core/test/tool-schema.test.ts +++ b/packages/core/test/tool-schema.test.ts @@ -32,6 +32,67 @@ test("tools are structural values", async () => { }) }) +test("Effect tool schemas use exact optional keys and flatten compatible constraints", () => { + const tool: Info = { + name: "constraints", + description: "Constraints", + input: Schema.Struct({ + offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + code: Schema.String.check(Schema.isPattern(/^a/), Schema.isPattern(/z$/)), + }), + execute: () => Effect.succeed({ content: "unused" }), + } + + expect(definition(tool).inputSchema).toEqual({ + type: "object", + properties: { + offset: { type: "integer", minimum: 0 }, + code: { type: "string", allOf: [{ pattern: "^a" }, { pattern: "z$" }] }, + }, + required: ["code"], + additionalProperties: false, + }) +}) + +test("Effect tool schemas inline named child schemas", () => { + const Child = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Child" }) + const tool: Info = { + name: "references", + description: "References", + input: Schema.Struct({ child: Child.annotate({ description: "Child value" }) }), + execute: () => Effect.succeed({ content: "unused" }), + } + + expect(definition(tool).inputSchema).toEqual({ + type: "object", + properties: { + child: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + description: "Child value", + }, + }, + required: ["child"], + additionalProperties: false, + }) +}) + +test("Effect tool schemas resolve escaped definition names", () => { + const Slash = Schema.Struct({ slash: Schema.String }).annotate({ identifier: "A/B" }) + const Tilde = Schema.Struct({ tilde: Schema.String }).annotate({ identifier: "A~B" }) + const tool: Info = { + name: "escaped-references", + description: "Escaped references", + input: Schema.Struct({ slash: Slash, tilde: Tilde }), + execute: () => Effect.succeed({ content: "unused" }), + } + + expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$ref") + expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$defs") +}) + test("portable schemas validate and describe typed tools", async () => { const input = { "~standard": {