refactor(codemode): expose string input as a tool option

This commit is contained in:
Aiden Cline
2026-11-04 13:02:23 -06:00
Unverified
parent 08ff94a1fb
commit 22dd6dfed4
6 changed files with 81 additions and 27 deletions
+3 -2
View File
@@ -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:
+1 -1
View File
@@ -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)})`
}
+39 -18
View File
@@ -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
}
+5 -4
View File
@@ -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>),
})
+31
View File
@@ -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,
}) {}
+2 -2
View File
@@ -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)),
})