mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
refactor(codemode): expose string input as a tool option
This commit is contained in:
@@ -61,8 +61,9 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
|
||||
|
||||
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
|
||||
|
||||
For a tool whose input has one required string field, `freeformField: "patchText"` advertises `tools.patch(input: string)`.
|
||||
Both `tools.patch(diff)` and `tools.patch({ patchText: diff })` deliver `{ patchText: diff }` to validation, hooks, and the executor.
|
||||
Set `stringInput: true` on a tool whose input is an object with one required string field to accept the string directly.
|
||||
For example, with `input: Schema.Struct({ patchText: Schema.String })`, Code Mode advertises `tools.patch(input: string)`.
|
||||
Both `tools.patch(diff)` and `tools.patch({ patchText: diff })` deliver `{ patchText: diff }` to validation, hooks, and the executor. Other input shapes keep their declared signature.
|
||||
|
||||
Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description:
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
|
||||
}
|
||||
|
||||
function inputSignature<R>(tool: Tool<R>) {
|
||||
if (tool.freeformField !== undefined) return "(input: string)"
|
||||
if (tool.stringInputField !== undefined) return "(input: string)"
|
||||
return isEmptyInput(tool) ? "()" : `(${inputTypeScript(tool, true)})`
|
||||
}
|
||||
|
||||
|
||||
@@ -250,35 +250,55 @@ export type InputProperty = {
|
||||
readonly required: boolean
|
||||
}
|
||||
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
function inspectInput<A>(
|
||||
input: SchemaType,
|
||||
read: (schema: JsonSchema, resolve: (schema: JsonSchema) => JsonSchema | undefined) => A,
|
||||
) {
|
||||
try {
|
||||
const document = isEffectSchema(tool.input)
|
||||
? (Schema.toJsonSchemaDocument(tool.input, { onExcessProperty: "error" }) as {
|
||||
const document = isEffectSchema(input)
|
||||
? (Schema.toJsonSchemaDocument(input, { onExcessProperty: "error" }) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
: {
|
||||
schema: tool.input,
|
||||
definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) },
|
||||
schema: input,
|
||||
definitions: { ...(input.definitions ?? {}), ...(input.$defs ?? {}) },
|
||||
}
|
||||
let schema = document.schema
|
||||
if (schema.$ref !== undefined) {
|
||||
const resolve = (schema: JsonSchema) => {
|
||||
if (schema.$ref === undefined) return schema
|
||||
const name = definitionName(schema.$ref)
|
||||
const resolved = name === undefined ? undefined : document.definitions[name]
|
||||
if (resolved === undefined) return []
|
||||
schema = resolved
|
||||
return name === undefined ? undefined : document.definitions[name]
|
||||
}
|
||||
const required = new Set(schema.required ?? [])
|
||||
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
|
||||
name,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
required: required.has(name),
|
||||
}))
|
||||
const root = resolve(document.schema)
|
||||
return root && read(root, resolve)
|
||||
} catch {
|
||||
return []
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
return (
|
||||
inspectInput(tool.input, (schema) => {
|
||||
const required = new Set(schema.required ?? [])
|
||||
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
|
||||
name,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
required: required.has(name),
|
||||
}))
|
||||
}) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function stringInputField(input: SchemaType) {
|
||||
return inspectInput(input, (schema, resolve) => {
|
||||
if (schema.type !== undefined && schema.type !== "object") return
|
||||
const fields = Object.entries(schema.properties ?? {})
|
||||
if (fields.length !== 1) return
|
||||
const [key, field] = fields[0]!
|
||||
if (schema.required?.length === 1 && schema.required[0] === key && resolve(field)?.type === "string") return key
|
||||
})
|
||||
}
|
||||
|
||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
|
||||
@@ -299,7 +319,8 @@ export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
: jsonSchemaToTypeScript(tool.output, pretty)
|
||||
|
||||
export const decodeInput = <R>(tool: Tool<R>, value: unknown): unknown => {
|
||||
const input = tool.freeformField !== undefined && typeof value === "string" ? { [tool.freeformField]: value } : value
|
||||
const input =
|
||||
tool.stringInputField !== undefined && typeof value === "string" ? { [tool.stringInputField]: value } : value
|
||||
return isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(input) : input
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Namespace } from "./namespace.js"
|
||||
import type { Tools } from "./tools.js"
|
||||
import { stringInputField } from "./tool-schema.js"
|
||||
|
||||
/**
|
||||
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
|
||||
@@ -46,7 +47,7 @@ export type Tool<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
readonly input: SchemaType
|
||||
readonly freeformField?: string
|
||||
readonly stringInputField?: string
|
||||
readonly output: SchemaType | undefined
|
||||
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||
}
|
||||
@@ -59,8 +60,8 @@ type ResultType<S> = S extends undefined ? void : S extends Schema.Decoder<unkno
|
||||
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
|
||||
readonly description: string
|
||||
readonly input: I
|
||||
/** Expose a single required string field as a positional argument. Object calls still use the input schema. */
|
||||
readonly freeformField?: string
|
||||
/** Accept the value of a single required string field directly, as well as the usual input object. */
|
||||
readonly stringInput?: boolean
|
||||
readonly output?: O
|
||||
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
||||
}
|
||||
@@ -81,7 +82,7 @@ export const make = <I extends SchemaType, const O extends SchemaType | undefine
|
||||
_tag: "CodeModeTool",
|
||||
description: options.description,
|
||||
input: options.input,
|
||||
freeformField: options.freeformField,
|
||||
stringInputField: options.stringInput ? stringInputField(options.input) : undefined,
|
||||
output: options.output,
|
||||
execute: (input) => options.execute(input as InputType<I>),
|
||||
})
|
||||
|
||||
@@ -5,6 +5,37 @@ import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
|
||||
const run = (tool: Tool.Tool<never>) =>
|
||||
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
||||
|
||||
test("stringInput derives the field from the encoded schema and keeps object calls working", async () => {
|
||||
const lookup = Tool.make({
|
||||
description: "Look up an ID",
|
||||
input: Schema.Struct({ id: Schema.NumberFromString.annotate({ identifier: "ID" }) }).annotate({
|
||||
identifier: "Lookup",
|
||||
}),
|
||||
stringInput: true,
|
||||
output: Schema.Number,
|
||||
execute: ({ id }) => Effect.succeed(id + 1),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { lookup } })
|
||||
expect(runtime.catalog[0]?.signature).toBe("tools.lookup(input: string): Promise<number>")
|
||||
expect(
|
||||
await Effect.runPromise(runtime.execute('return [await tools.lookup("1"), await tools.lookup({id:"2"})]')),
|
||||
).toMatchObject({ ok: true, value: [2, 3] })
|
||||
})
|
||||
|
||||
test.each([
|
||||
Schema.Struct({ text: Schema.optionalKey(Schema.String) }),
|
||||
Schema.Struct({ count: Schema.Number }),
|
||||
Schema.Struct({ text: Schema.String, tag: Schema.optionalKey(Schema.String) }),
|
||||
])("stringInput leaves ineligible schemas object-shaped", async (input) => {
|
||||
const tool = Tool.make({ description: "Inspect", input, stringInput: true, execute: () => Effect.void })
|
||||
const runtime = CodeMode.make({ tools: { inspect: tool } })
|
||||
expect(runtime.catalog[0]?.signature).toStartWith("tools.inspect({")
|
||||
expect(await Effect.runPromise(runtime.execute('return await tools.inspect("value")'))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "InvalidToolInput" },
|
||||
})
|
||||
})
|
||||
|
||||
class HostError extends Schema.TaggedError<HostError>()("HostError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as CodeModeTool from "./tool.js"
|
||||
|
||||
import { ToolDefinition, ToolFailure } from "@opencode/ai/effect"
|
||||
import { ToolFailure } from "@opencode/ai/effect"
|
||||
import { CodeMode, Namespace, Tool } from "@opencode/codemode"
|
||||
import type {
|
||||
Content,
|
||||
@@ -305,7 +305,7 @@ function inventoryTools(inventory: Inventory) {
|
||||
getNode(root, qualifiedName(registration)).tool = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
freeformField: child.freeform && ToolDefinition.freeformField(child.inputSchema),
|
||||
stringInput: child.freeform !== undefined,
|
||||
output: child.outputSchema ?? Schema.NullOr(Schema.String),
|
||||
execute: (input) => Run.use((run) => run.executeTool(name, registration, input)),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user