mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
feat(codemode): accept raw strings for freeform tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>),
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user