From 08ff94a1fb270d82c59145671426ae7a496e4ad0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 4 Nov 2026 12:53:21 -0600 Subject: [PATCH] feat(codemode): accept raw strings for freeform tools --- packages/codemode/README.md | 3 +++ packages/codemode/src/tool-runtime.ts | 9 +++++-- packages/codemode/src/tool-schema.ts | 6 +++-- packages/codemode/src/tool.ts | 4 ++++ packages/core/src/codemode/tool.ts | 3 ++- packages/core/test/tool-execute.test.ts | 24 +++++++++++++++++++ .../src/docs/content/build/plugins/server.mdx | 2 ++ 7 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 83e2408bd64..3bf3d39be67 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -61,6 +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. + Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description: ```ts diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 8660d4fd630..071e07b7276 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -179,13 +179,18 @@ const describeTool = (visible: VisibleTool): ToolDescription => { // Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC. return (signature ??= [ toolExpression(visible.path), - isEmptyInput(visible.tool) ? "()" : `(${inputTypeScript(visible.tool, true)})`, + inputSignature(visible.tool), `: Promise<${outputTypeScript(visible.tool, true)}>`, ].join("")) }, } } +function inputSignature(tool: Tool) { + if (tool.freeformField !== undefined) return "(input: string)" + return isEmptyInput(tool) ? "()" : `(${inputTypeScript(tool, true)})` +} + /** Tools indexed once per runtime, with discovery materialized on demand. */ export type Prepared = { readonly root: ToolNode @@ -403,7 +408,7 @@ export const make = ( Effect.gen(function* () { const normalized = args.length === 0 ? [{}] : args if (normalized.length !== 1) - throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects at most one input object.`) + throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects at most one argument.`) const input = yield* Effect.try({ try: () => decodeToolInput(tool, normalized[0]), catch: (cause) => diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 04e8bb5e1ed..ee48e1fffbd 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -298,8 +298,10 @@ export const outputTypeScript = (tool: Tool, pretty = false): string => ? toTypeScript(tool.output, true, pretty) : jsonSchemaToTypeScript(tool.output, pretty) -export const decodeInput = (tool: Tool, value: unknown): unknown => - isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value +export const decodeInput = (tool: Tool, value: unknown): unknown => { + const input = tool.freeformField !== undefined && typeof value === "string" ? { [tool.freeformField]: value } : value + return isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(input) : input +} export const decodeOutput = (tool: Tool, value: unknown): unknown => tool.output === undefined diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index e39138fd595..a06fcd18249 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -46,6 +46,7 @@ export type Tool = { readonly _tag: "CodeModeTool" readonly description: string readonly input: SchemaType + readonly freeformField?: string readonly output: SchemaType | undefined readonly execute: (input: unknown) => Effect.Effect } @@ -58,6 +59,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 readonly output?: O readonly execute: (input: InputType) => Effect.Effect, unknown, R> } @@ -78,6 +81,7 @@ export const make = options.execute(input as InputType), }) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 1fd67439350..df02bbd1c4c 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 { ToolFailure } from "@opencode/ai/effect" +import { ToolDefinition, ToolFailure } from "@opencode/ai/effect" import { CodeMode, Namespace, Tool } from "@opencode/codemode" import type { Content, @@ -305,6 +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), output: child.outputSchema ?? Schema.NullOr(Schema.String), execute: (input) => Run.use((run) => run.executeTool(name, registration, input)), }) diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index aad5df3f57f..2e16ae72732 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -25,6 +25,30 @@ const allowAll = permissionService({ assert: () => Effect.void }) const createCodeMode = (tools: ReadonlyMap, permission = allowAll) => CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context), permission) +test("Code Mode lists one string argument for freeform tools and executes both input shapes", async () => { + const input = Schema.Struct({ text: Schema.NonEmptyString }) + const echo = { + name: "echo", + description: "Echo text", + input, + output: Schema.String, + execute: ({ text }) => Effect.succeed({ output: text }), + } satisfies Info + const tools = new Map([ + ["echo", echo], + ["send", { ...echo, name: "send", freeform: { type: "text" } }], + ]) + const sendSignature = "tools.send(input: string): Promise" + expect(CodeModeTool.catalog({ tools }).tools).toContainEqual(expect.objectContaining({ signature: sendSignature })) + const run = (code: string) => Effect.runPromise(createCodeMode(tools).execute({ code }, context)).then((r) => r.output) + expect((await run('return search({ query: "tools.send" }).items[0].signature')).output).toBe(sendSignature) + const calls = await run('return [await tools.send("first"), await tools.send({text: "second"})]') + expect(JSON.parse(calls.output)).toEqual(["first", "second"]) + expect(calls.toolCalls.map((call) => call.input)).toEqual([{ text: "first" }, { text: "second" }]) + expect((await run('return await tools.send("")')).error).toBe(true) + expect((await run('return await tools.echo("object required")')).error).toBe(true) +}) + test("execute describes invariant Code Mode behavior", () => { expect(createCodeMode(new Map()).description).toBe( [ diff --git a/services/www/src/docs/content/build/plugins/server.mdx b/services/www/src/docs/content/build/plugins/server.mdx index 21170ed9c0a..e219a4cb6b3 100644 --- a/services/www/src/docs/content/build/plugins/server.mdx +++ b/services/www/src/docs/content/build/plugins/server.mdx @@ -1027,6 +1027,8 @@ editor.add({ If the selected model's API lists `regex` in models.dev, the model can write `OPS-42` directly. Otherwise Core omits **only** `freeform`: the model still has the `ticket` tool and calls it with `{"id":"OPS-42"}`. Missing or empty format lists mean no freeform support. The executor receives `{ id }` either way. +If you expose the same tool inside Code Mode (`codemode: true`), its JavaScript signature takes one string: `await tools.ticket("OPS-42")`. Code Mode also accepts `await tools.ticket({ id: "OPS-42" })` for existing scripts. Both forms work independently of the model's freeform support. + Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without changing their order; it does not rerun plugin setup.