fix(core): simplify tool schemas (#39184)

This commit is contained in:
Aiden Cline 2026-07-27 16:04:48 -05:00 committed by GitHub
parent 4f622fa7cd
commit 31124312f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 174 additions and 33 deletions

View file

@ -35,15 +35,15 @@ export const DEFAULT_SEARCH_LIMIT = 100
export class GlobInput extends Schema.Class<GlobInput>("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<GrepInput>("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

View file

@ -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)",
}),
})

View file

@ -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({

View file

@ -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({

View file

@ -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,

View file

@ -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

View file

@ -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.",
}),

View file

@ -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})`,
}),
})

View file

@ -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<TextPage>("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<ListPage>("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) =>

View file

@ -100,8 +100,89 @@ const outputJsonSchema = (schema: Tool.ValueSchema<any>): 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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const canFlattenAllOf = (allOf: ReadonlyArray<Record<string, unknown>>, parent: Record<string, unknown>) => {
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<string, unknown>,
seen = new Set<string>(),
): 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<string, unknown>, right: Record<string, unknown>) =>
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<Tool.Content> | undefined, output?: unknown) => {

View file

@ -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": {