mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
feat(codemode): flatten top-level tool arguments on opt-in
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}) {}
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user