From 22dd6dfed49469f319c5af0dadaa8ea61179d0e0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 4 Nov 2026 13:02:23 -0600 Subject: [PATCH] refactor(codemode): expose string input as a tool option --- packages/codemode/README.md | 5 ++- packages/codemode/src/tool-runtime.ts | 2 +- packages/codemode/src/tool-schema.ts | 57 +++++++++++++++++-------- packages/codemode/src/tool.ts | 9 ++-- packages/codemode/test/codemode.test.ts | 31 ++++++++++++++ packages/core/src/codemode/tool.ts | 4 +- 6 files changed, 81 insertions(+), 27 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 3bf3d39be67..abf5ecb030d 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -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: diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 071e07b7276..4fac093059e 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -187,7 +187,7 @@ const describeTool = (visible: VisibleTool): ToolDescription => { } function inputSignature(tool: Tool) { - if (tool.freeformField !== undefined) return "(input: string)" + if (tool.stringInputField !== undefined) return "(input: string)" return isEmptyInput(tool) ? "()" : `(${inputTypeScript(tool, true)})` } diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index ee48e1fffbd..6a7c4b07bfd 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -250,35 +250,55 @@ export type InputProperty = { readonly required: boolean } -export const inputProperties = (tool: Tool): Array => { +function inspectInput( + 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> }) : { - 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 = (tool: Tool): Array => { + 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 = (tool: Tool, pretty = false): string => isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty) @@ -299,7 +319,8 @@ export const outputTypeScript = (tool: Tool, pretty = false): string => : jsonSchemaToTypeScript(tool.output, pretty) export const decodeInput = (tool: Tool, 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 } diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index a06fcd18249..f3fdbcbed5f 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -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 = { 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 } @@ -59,8 +60,8 @@ type ResultType = S extends undefined ? void : S extends Schema.Decoder = { 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) => Effect.Effect, unknown, R> } @@ -81,7 +82,7 @@ export const make = options.execute(input as InputType), }) diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 225805e403f..b1ad40bcab4 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -5,6 +5,37 @@ import { CodeMode, Extension, Tool, toolError } from "../src/index.js" const run = (tool: Tool.Tool) => 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") + 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", { message: Schema.String, }) {} diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index df02bbd1c4c..49175244986 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -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)), })