feat(codemode): accept raw strings for freeform tools

This commit is contained in:
Aiden Cline
2026-11-04 12:53:21 -06:00
Unverified
parent beb54dae17
commit 08ff94a1fb
7 changed files with 46 additions and 5 deletions
+3
View File
@@ -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
+7 -2
View File
@@ -179,13 +179,18 @@ const describeTool = <R>(visible: VisibleTool<R>): 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<R>(tool: Tool<R>) {
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<R = never> = {
readonly root: ToolNode<R>
@@ -403,7 +408,7 @@ export const make = <R>(
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) =>
+4 -2
View File
@@ -298,8 +298,10 @@ 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 =>
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value
export const decodeInput = <R>(tool: Tool<R>, 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 = <R>(tool: Tool<R>, value: unknown): unknown =>
tool.output === undefined
+4
View File
@@ -46,6 +46,7 @@ export type Tool<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: SchemaType
readonly freeformField?: string
readonly output: SchemaType | undefined
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
@@ -58,6 +59,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
/** 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<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
@@ -78,6 +81,7 @@ export const make = <I extends SchemaType, const O extends SchemaType | undefine
_tag: "CodeModeTool",
description: options.description,
input: options.input,
freeformField: options.freeformField,
output: options.output,
execute: (input) => options.execute(input as InputType<I>),
})
+2 -1
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,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)),
})
+24
View File
@@ -25,6 +25,30 @@ const allowAll = permissionService({ assert: () => Effect.void })
const createCodeMode = (tools: ReadonlyMap<string, Info>, 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<typeof input, typeof Schema.String>
const tools = new Map<string, Info>([
["echo", echo],
["send", { ...echo, name: "send", freeform: { type: "text" } }],
])
const sendSignature = "tools.send(input: 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)
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(
[
@@ -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.