feat(codemode): flatten top-level tool arguments on opt-in

This commit is contained in:
Aiden Cline
2026-11-04 13:33:08 -06:00
Unverified
parent 22dd6dfed4
commit b457a30727
9 changed files with 222 additions and 44 deletions
+7 -3
View File
@@ -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<string>)`. 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:
+8 -5
View File
@@ -187,7 +187,7 @@ const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
}
function inputSignature<R>(tool: Tool<R>) {
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 = <R>(
const executeTool = (name: string, tool: Tool<R>, args: Array<Json | undefined>) =>
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",
+58 -15
View File
@@ -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<unknown> & Schema.Top => Schema.isSchema(schema)
@@ -252,7 +253,7 @@ export type InputProperty = {
function inspectInput<A>(
input: SchemaType,
read: (schema: JsonSchema, resolve: (schema: JsonSchema) => JsonSchema | undefined) => A,
read: (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>) => A,
) {
try {
const document = isEffectSchema(input)
@@ -270,7 +271,7 @@ function inspectInput<A>(
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 = <R>(tool: Tool<R>): Array<InputProperty> => {
)
}
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<Json | undefined>) {
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<Schema.Codec<unknown>>(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 = <R>(tool: Tool<R>, pretty = false): string =>
? toTypeScript(tool.output, true, pretty)
: jsonSchemaToTypeScript(tool.output, pretty)
export const decodeInput = <R>(tool: Tool<R>, 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 = <R>(tool: Tool<R>, input: unknown): unknown =>
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(input) : input
export const decodeOutput = <R>(tool: Tool<R>, value: unknown): unknown =>
tool.output === undefined
+5 -5
View File
@@ -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<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: SchemaType
readonly stringInputField?: string
readonly parameters?: ReturnType<typeof flatParameters>
readonly output: SchemaType | undefined
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
@@ -60,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
/** 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<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
@@ -82,7 +82,7 @@ export const make = <I extends SchemaType, const O extends SchemaType | undefine
_tag: "CodeModeTool",
description: options.description,
input: options.input,
stringInputField: options.stringInput ? stringInputField(options.input) : undefined,
parameters: options.flat ? flatParameters(options.input) : undefined,
output: options.output,
execute: (input) => options.execute(input as InputType<I>),
})
+103 -12
View File
@@ -5,37 +5,128 @@ 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 () => {
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<number>")
expect(runtime.catalog[0]?.signature).toBe("tools.lookup(id: 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({
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<string>): Promise<unknown>"
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<unknown>")
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>()("HostError", {
message: Schema.String,
}) {}
+4 -2
View File
@@ -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)),
})
+24 -2
View File
@@ -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<string>"
const sendSignature = "tools.send(text: string): Promise<string>"
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<string>"
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(
[
+3
View File
@@ -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
}
)
@@ -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.