mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 11:49:52 +00:00
fix(core): simplify tool schemas (#39184)
This commit is contained in:
parent
4f622fa7cd
commit
31124312f6
11 changed files with 174 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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})`,
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue