From b457a307278dc708255eee1e50fc14a06d97e2c9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 4 Nov 2026 13:33:08 -0600 Subject: [PATCH] feat(codemode): flatten top-level tool arguments on opt-in --- packages/codemode/README.md | 10 +- packages/codemode/src/tool-runtime.ts | 13 +- packages/codemode/src/tool-schema.ts | 73 ++++++++--- packages/codemode/src/tool.ts | 10 +- packages/codemode/test/codemode.test.ts | 115 ++++++++++++++++-- packages/core/src/codemode/tool.ts | 6 +- packages/core/test/tool-execute.test.ts | 26 +++- packages/schema/src/tool.ts | 3 + .../src/docs/content/build/plugins/server.mdx | 10 ++ 9 files changed, 222 insertions(+), 44 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index abf5ecb030d..6b4bb31cfdf 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -61,9 +61,13 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`. -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. +Set `flat: true` to expose an object's declared top-level fields as positional arguments, in schema declaration order. +For `{ path: string, options: { recursive: boolean }, tags?: string[] }`, the catalog and `search()` show +`tools.scan(path: string, options: { recursive: boolean }, tags?: Array)`. A call such as +`tools.scan("src", { recursive: true }, ["ts"])` delivers `{ path: "src", options: { recursive: true }, tags: ["ts"] }` +to validation, hooks, and the executor. Nested objects and arrays occupy one position each. Omit trailing optional +fields or pass `undefined` to skip a position. Existing named-object calls still work; when a lone object matches both +forms, the named form wins. Schemas without a fixed set of named fields, such as dictionaries, keep the object call. 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 4fac093059e..4e37979452a 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.stringInputField !== undefined) return "(input: string)" + if (tool.parameters) return tool.parameters.signature return isEmptyInput(tool) ? "()" : `(${inputTypeScript(tool, true)})` } @@ -406,11 +406,14 @@ export const make = ( const executeTool = (name: string, tool: Tool, args: Array) => Effect.gen(function* () { - const normalized = args.length === 0 ? [{}] : args - if (normalized.length !== 1) - throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects at most one argument.`) + const limit = tool.parameters?.length ?? 1 + if (args.length > limit) + throw new ToolRuntimeError( + "InvalidToolInput", + `Tool '${name}' expects at most ${limit === 1 ? "one argument" : `${limit} arguments`}.`, + ) const input = yield* Effect.try({ - try: () => decodeToolInput(tool, normalized[0]), + try: () => decodeToolInput(tool, tool.parameters ? tool.parameters.bind(args) : args.length ? args[0] : {}), catch: (cause) => new ToolRuntimeError( "InvalidToolInput", diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 6a7c4b07bfd..1da5b431c7e 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -1,4 +1,5 @@ -import { JsonPointer, Schema } from "effect" +import { JsonPointer, Option, Result, Schema, SchemaRepresentation } from "effect" +import type { Json } from "./data.js" import type { Tool, JsonSchema, SchemaType } from "./tool.js" const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) @@ -252,7 +253,7 @@ export type InputProperty = { function inspectInput( input: SchemaType, - read: (schema: JsonSchema, resolve: (schema: JsonSchema) => JsonSchema | undefined) => A, + read: (schema: JsonSchema, definitions: Readonly>) => A, ) { try { const document = isEffectSchema(input) @@ -270,7 +271,7 @@ function inspectInput( return name === undefined ? undefined : document.definitions[name] } const root = resolve(document.schema) - return root && read(root, resolve) + return root && read(root, document.definitions) } catch { return undefined } @@ -289,13 +290,58 @@ export const inputProperties = (tool: Tool): Array => { ) } -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 +const reserved = new Set( + "arguments await break case catch class const continue debugger default delete do else enum eval export extends false finally for function if implements import in instanceof interface let new null package private protected public return static super switch this throw true try typeof var void while with yield".split( + " ", + ), +) + +export function flatParameters(input: SchemaType) { + return inspectInput(input, (schema, definitions) => { + if ((schema.type !== undefined && schema.type !== "object") || schema.anyOf || schema.oneOf || schema.allOf) return + const properties = schema.properties + if (!properties || Object.hasOwn(properties, "__proto__")) return + const fields = Object.entries(properties) + if (fields.length === 0 || schema.required?.some((key) => !Object.hasOwn(properties, key))) return + const required = new Set(schema.required) + const lastRequired = fields.findLastIndex(([key]) => required.has(key)) + const firstType = fields[0]![1].type + const objectFirst = + firstType === undefined || firstType === "object" || (Array.isArray(firstType) && firstType.includes("object")) + let matches: ((value: unknown) => boolean) | undefined + return { + length: fields.length, + get signature() { + const named = fields.every(([key]) => identifierSegment.test(key) && !reserved.has(key)) + const parameters = fields.map(([key, field], i) => { + const label = named ? key : `arg${i + 1}` + const type = renderSchema(field, { definitions, pretty: false }) + if (i > lastRequired) return `${label}?: ${type}` + return `${label}: ${required.has(key) ? type : `${type} | undefined`}` + }) + return `(${parameters.join(", ")})` + }, + bind(args: ReadonlyArray) { + const object = Object.fromEntries(args.flatMap((arg, i) => (arg === undefined ? [] : [[fields[i]![0], arg]]))) + const first = args[0] + if (args.length !== 1 || first === null || typeof first !== "object" || Array.isArray(first)) return object + // A lone object can be either the old named call or the first positional value. Prefer the + // named call when both match. Probe only the wire schema; run the tool's actual decoder once. + if (!objectFirst || lastRequired > 0) return first + matches ??= Result.try(() => { + const document = SchemaRepresentation.fromJsonSchemaDocument({ + dialect: "draft-2020-12", + schema, + definitions, + }) + const decode = Schema.decodeUnknownOption(Schema.make>(document.ast), { + onExcessProperty: "error", + }) + return (value: unknown) => Option.isSome(decode(value)) + }).pipe(Result.getOrElse(() => () => true)) // Preserve named calls if a render-only schema cannot be compiled. + return matches(first) || !matches(object) ? first : object + }, + } }) } @@ -318,11 +364,8 @@ 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 => { - const input = - tool.stringInputField !== undefined && typeof value === "string" ? { [tool.stringInputField]: value } : value - return isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(input) : input -} +export const decodeInput = (tool: Tool, input: unknown): unknown => + 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 f3fdbcbed5f..f8087f3208a 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -1,7 +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" +import { flatParameters } from "./tool-schema.js" /** * JSON Schema subset for model-visible signatures. CodeMode does not validate values against @@ -47,7 +47,7 @@ export type Tool = { readonly _tag: "CodeModeTool" readonly description: string readonly input: SchemaType - readonly stringInputField?: string + readonly parameters?: ReturnType readonly output: SchemaType | undefined readonly execute: (input: unknown) => Effect.Effect } @@ -60,8 +60,8 @@ type ResultType = S extends undefined ? void : S extends Schema.Decoder = { readonly description: string readonly input: I - /** Accept the value of a single required string field directly, as well as the usual input object. */ - readonly stringInput?: boolean + /** Expose named top-level fields as positional arguments in schema order. The executor receives the object. */ + readonly flat?: boolean readonly output?: O readonly execute: (input: InputType) => Effect.Effect, unknown, R> } @@ -82,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 b1ad40bcab4..c59659c121c 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -5,37 +5,128 @@ 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 () => { +test("flat parameters use encoded field types and keep named 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, + flat: 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(runtime.catalog[0]?.signature).toBe("tools.lookup(id: 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({ +test("flat calls preserve field order, nested values, and optional slots", async () => { + const scan = Tool.make({ + description: "Scan a directory", + flat: true, + input: Schema.Struct({ + path: Schema.optionalKey(Schema.String), + options: Schema.Struct({ recursive: Schema.Boolean }), + tags: Schema.optionalKey(Schema.Array(Schema.String)), + }), + output: Schema.Json, + execute: (input) => Effect.succeed(input), + }) + const runtime = CodeMode.make({ tools: { scan } }) + const signature = + "tools.scan(path: string | undefined, options: { recursive: boolean }, tags?: Array): Promise" + expect(runtime.catalog[0]?.signature).toBe(signature) + const run = (code: string) => Effect.runPromise(runtime.execute(code)) + expect(await run('return search({query:"scan"}).items[0].signature')).toMatchObject({ value: signature }) + const options = { recursive: true } + expect(await run('return await tools.scan("src", {recursive:true}, ["ts"])')).toMatchObject({ + ok: true, + value: { path: "src", options, tags: ["ts"] }, + }) + expect(await run("return await tools.scan(undefined, {recursive:true})")).toEqual({ + ok: true, + value: { options }, + toolCalls: [{ name: "scan" }], + }) + expect(await run("return await tools.scan({options:{recursive:true}})")).toMatchObject({ + ok: true, + value: { options }, + }) + expect(await run('return await tools.scan("src")')).toMatchObject({ ok: false, error: { kind: "InvalidToolInput" } }) + expect(await run('return await tools.scan("src", {recursive:true}, [], 4)')).toMatchObject({ ok: false, error: { kind: "InvalidToolInput" }, + toolCalls: [], }) }) +test("a lone positional object is wrapped only when the named form does not fit", async () => { + const input = { + type: "object", + properties: { item: { type: "object" } }, + required: ["item"], + additionalProperties: false, + } + const collect = Tool.make({ + description: "Collect", + flat: true, + input, + output: {}, + execute: (value) => Effect.succeed(value), + }) + const runtime = CodeMode.make({ tools: { collect } }) + const result = await Effect.runPromise( + runtime.execute( + `return [await tools.collect({value:1}), await tools.collect({item:{value:2}}), await tools.collect({item:3})]`, + ), + ) + expect(result).toMatchObject({ + ok: true, + value: [{ item: { value: 1 } }, { item: { value: 2 } }, { item: { item: 3 } }], + }) +}) + +test("flat keeps a dictionary without declared fields on its object signature", async () => { + const count = Tool.make({ + description: "Count keys", + input: Schema.Record(Schema.String, Schema.String), + output: Schema.Int, + flat: true, + execute: (input) => Effect.succeed(Object.keys(input).length), + }) + const runtime = CodeMode.make({ tools: { count } }) + expect(runtime.catalog[0]?.signature).toContain("[key: string]: string") + expect(await Effect.runPromise(runtime.execute('return await tools.count({one:"1",two:"2"})'))).toMatchObject({ + ok: true, + value: 2, + }) +}) + +test("flat binds non-identifier keys, optional falsey values and local references", async () => { + const save = Tool.make({ + description: "Save a note", + flat: true, + input: { + $ref: "#/$defs/Save", + $defs: { + Save: { type: "object", properties: { "dry-run": { type: "boolean" }, default: { $ref: "#/$defs/Value" } } }, + Value: { type: ["string", "null"] }, + }, + }, + output: {}, + execute: (value) => Effect.succeed(value), + }) + const runtime = CodeMode.make({ tools: { save } }) + expect(runtime.catalog[0]?.signature).toBe("tools.save(arg1?: boolean, arg2?: string | null): Promise") + expect( + await Effect.runPromise( + runtime.execute('return [await tools.save(false, null), await tools.save(undefined, ""), await tools.save()]'), + ), + ).toMatchObject({ ok: true, value: [{ "dry-run": false, default: null }, { default: "" }, {}] }) +}) + 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 49175244986..6125b8e3b89 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,7 +305,9 @@ function inventoryTools(inventory: Inventory) { getNode(root, qualifiedName(registration)).tool = Tool.make({ description: child.description, input: child.inputSchema, - stringInput: child.freeform !== undefined, + flat: + registration.options?.flat ?? + (child.freeform !== undefined && ToolDefinition.freeformField(child.inputSchema) !== undefined), 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 2e16ae72732..7d727f603ed 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -38,9 +38,10 @@ test("Code Mode lists one string argument for freeform tools and executes both i ["echo", echo], ["send", { ...echo, name: "send", freeform: { type: "text" } }], ]) - const sendSignature = "tools.send(input: string): Promise" + const sendSignature = "tools.send(text: 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) + 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"]) @@ -49,6 +50,27 @@ test("Code Mode lists one string argument for freeform tools and executes both i expect((await run('return await tools.echo("object required")')).error).toBe(true) }) +test("Core tools can opt into positional Code Mode calls with multiple fields", async () => { + const tool = { + name: "scan", + description: "Scan a path", + options: { flat: true }, + input: Schema.Struct({ path: Schema.String, options: Schema.Struct({ recursive: Schema.Boolean }) }), + output: Schema.String, + execute: ({ path, options }) => Effect.succeed({ output: `${path}:${options.recursive}` }), + } satisfies Info + const tools = new Map([[tool.name, tool]]) + const signature = "tools.scan(path: string, options: { recursive: boolean }): Promise" + expect(CodeModeTool.catalog({ tools }).tools).toContainEqual(expect.objectContaining({ signature })) + const result = await Effect.runPromise( + createCodeMode(tools).execute({ code: 'return await tools.scan("src", {recursive:true})' }, context), + ) + expect(result.output).toMatchObject({ + output: "src:true", + toolCalls: [{ input: { path: "src", options: { recursive: true } } }], + }) +}) + test("execute describes invariant Code Mode behavior", () => { expect(createCodeMode(new Map()).description).toBe( [ diff --git a/packages/schema/src/tool.ts b/packages/schema/src/tool.ts index d07b52dc5a5..ad9768918d0 100644 --- a/packages/schema/src/tool.ts +++ b/packages/schema/src/tool.ts @@ -47,10 +47,13 @@ export type Options = BaseOptions & | { readonly codemode?: true readonly pinned?: boolean + /** Offer top-level fields as positional arguments in Code Mode. */ + readonly flat?: boolean } | { readonly codemode: boolean readonly pinned?: never + readonly flat?: never } ) diff --git a/services/www/src/docs/content/build/plugins/server.mdx b/services/www/src/docs/content/build/plugins/server.mdx index e219a4cb6b3..a684f2407ab 100644 --- a/services/www/src/docs/content/build/plugins/server.mdx +++ b/services/www/src/docs/content/build/plugins/server.mdx @@ -1029,6 +1029,16 @@ If the selected model's API lists `regex` in models.dev, the model can write `OP 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. +#### Positional arguments in Code Mode + +Set `options: { flat: true }` to expose a tool's top-level fields as positional arguments in schema order. For example, `input: Schema.Struct({ path: Schema.String, options: Schema.Struct({ recursive: Schema.Boolean }) })` becomes: + +```js +await tools.scan("src", { recursive: true }) +``` + +The executor receives `{ path: "src", options: { recursive: true } }`. Named-object calls also work and take precedence when a single object fits both forms. Code Mode enables `flat` by default for eligible freeform tools; set `options.flat` to `false` to use the object signature. + 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.