diff --git a/.changeset/control-write.md b/.changeset/control-write.md new file mode 100644 index 0000000..0a849e1 --- /dev/null +++ b/.changeset/control-write.md @@ -0,0 +1,5 @@ +--- +"opencode-drive": patch +--- + +Restore controlled tools against the current V2 plugin API and add typed runtime control for write calls. diff --git a/packages/drive/src/tool/controller.ts b/packages/drive/src/tool/controller.ts index 246547a..dcf58e7 100644 --- a/packages/drive/src/tool/controller.ts +++ b/packages/drive/src/tool/controller.ts @@ -20,6 +20,8 @@ import { WebFetchResult, WebSearchInput, WebSearchResult, + WriteInput, + WriteResult, type Configuration, type ControlledCall, type ControlledCalls, @@ -32,9 +34,10 @@ import { type ShellHandler, type WebFetchHandler, type WebSearchHandler, + type WriteHandler, } from "./types.js" -type Result = ShellResult | WebFetchResult | WebSearchResult +type Result = ShellResult | WebFetchResult | WebSearchResult | WriteResult type Event = | { readonly type: "progress"; readonly result: Result } | { readonly type: "success"; readonly result: Result } @@ -51,7 +54,7 @@ type BackgroundJob = { readonly cancel: () => void } type Definition = { - readonly schema: typeof ShellInput | typeof WebFetchInput | typeof WebSearchInput + readonly schema: typeof ShellInput | typeof WebFetchInput | typeof WebSearchInput | typeof WriteInput readonly invoke: ( input: unknown, index: number, @@ -134,6 +137,7 @@ export const make = Effect.fn("ToolController.make")(function* (configuration?: function handle(name: "shell", handler: ShellHandler): void function handle(name: "webfetch", handler: WebFetchHandler): void function handle(name: "websearch", handler: WebSearchHandler): void + function handle(name: "write", handler: WriteHandler): void function handle(...registration: Registration) { switch (registration[0]) { case "shell": { @@ -188,6 +192,24 @@ export const make = Effect.fn("ToolController.make")(function* (configuration?: return yield* Schema.decodeUnknownEffect(WebSearchResult)(result) }), }) + return + } + case "write": { + const handler = registration[1] + add("write", { + schema: WriteInput, + invoke: (raw, index, id, progress) => + Effect.gen(function* () { + const input = yield* Schema.decodeUnknownEffect(WriteInput)(raw) + const result = yield* handler({ + id, + input, + index, + progress: (value) => progress(typeof value === "string" ? { output: value } : value), + }) + return yield* Schema.decodeUnknownEffect(WriteResult)(result) + }), + }) } } } @@ -217,6 +239,13 @@ export const make = Effect.fn("ToolController.make")(function* (configuration?: closeControls.push(controlled.close) break } + case "write": { + const controlled = makeControlledHandler("write") + handle("write", controlled.handler) + controlledCalls.write = controlled.calls + closeControls.push(controlled.close) + break + } default: throw new Error(`unsupported controlled tool: ${String(name)}`) } diff --git a/packages/drive/src/tool/plugin.js b/packages/drive/src/tool/plugin.js index 8428244..ec792e9 100644 --- a/packages/drive/src/tool/plugin.js +++ b/packages/drive/src/tool/plugin.js @@ -7,18 +7,26 @@ const descriptions = { shell: "Executes a shell command.", webfetch: "Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML.", websearch: "Search the web using the session's local web search provider.", + write: "Writes a file to the local filesystem, overwriting if one exists.", } class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { message: Schema.String, }) {} +const content = (result) => [ + { type: "text", text: result.output }, + ...(result.status === "running" ? [{ type: "text", text: BACKGROUND_INSTRUCTION }] : []), +] + const output = (result) => ({ + output: result, + content: content(result), +}) + +const progress = (result) => ({ structured: result, - content: [ - { type: "text", text: result.output }, - ...(result.status === "running" ? [{ type: "text", text: BACKGROUND_INSTRUCTION }] : []), - ], + content: content(result), }) const failure = (cause) => @@ -92,7 +100,7 @@ const execute = (ctx, scope, options, name, input, context) => }, body: JSON.stringify({ input, - context: { callID: context.callID }, + context: { callID: context.id }, }), signal, }), @@ -125,7 +133,7 @@ const execute = (ctx, scope, options, name, input, context) => buffer = buffer.slice(newline + 1) if (!line) continue const event = yield* parse(line) - if (event.type === "progress") yield* context.progress(output(event.result)) + if (event.type === "progress") yield* context.progress(progress(event.result)) if (event.type === "success") result = event.result if (event.type === "failure") return yield* new ToolFailure({ message: event.message }) @@ -151,15 +159,13 @@ export default { const scope = yield* Scope.Scope yield* ctx.tool.transform((tools) => { for (const name of ctx.options.tools) { - tools.add( + tools.add({ name, - { - description: descriptions[name], - jsonSchema: ctx.options.schemas[name], - execute: (input, context) => execute(ctx, scope, ctx.options, name, input, context), - }, - { codemode: false }, - ) + description: descriptions[name], + input: ctx.options.schemas[name], + options: { codemode: false }, + execute: (input, context) => execute(ctx, scope, ctx.options, name, input, context), + }) } }) }), diff --git a/packages/drive/src/tool/types.ts b/packages/drive/src/tool/types.ts index 87ea299..9e18ec6 100644 --- a/packages/drive/src/tool/types.ts +++ b/packages/drive/src/tool/types.ts @@ -61,12 +61,23 @@ export const WebSearchResult = Schema.Struct({ }) export interface WebSearchResult extends Schema.Schema.Type {} +export const WriteInput = Schema.Struct({ + path: Schema.String.annotate({ description: "Path to the file to write to" }), + content: Schema.String.annotate({ description: "Content to write to the file" }), +}) +export interface WriteInput extends Schema.Schema.Type {} + +export const WriteResult = Schema.Struct({ + output: Schema.String, +}) +export interface WriteResult extends Schema.Schema.Type {} + export class Failure extends Schema.TaggedErrorClass()( "OpenCodeDrive.ToolFailure", { message: Schema.String }, ) {} -export const Name = Schema.Literals(["shell", "webfetch", "websearch"]) +export const Name = Schema.Literals(["shell", "webfetch", "websearch", "write"]) export type Name = typeof Name.Type export const Names = Schema.Array(Name) @@ -102,11 +113,13 @@ export type Handler = ( export type ShellHandler = Handler export type WebFetchHandler = Handler export type WebSearchHandler = Handler +export type WriteHandler = Handler export interface ToolTypes { readonly shell: { readonly input: ShellInput; readonly result: ShellResult } readonly webfetch: { readonly input: WebFetchInput; readonly result: WebFetchResult } readonly websearch: { readonly input: WebSearchInput; readonly result: WebSearchResult } + readonly write: { readonly input: WriteInput; readonly result: WriteResult } } export type HandlerFor = Handler< diff --git a/packages/drive/test/tool/controller.test.ts b/packages/drive/test/tool/controller.test.ts index 65c9776..b1ca08a 100644 --- a/packages/drive/test/tool/controller.test.ts +++ b/packages/drive/test/tool/controller.test.ts @@ -9,9 +9,10 @@ import plugin from "../../src/tool/plugin.js" import type { OpenCodeConfig } from "../../src/script/types.js" interface RegisteredTool { + readonly name: string readonly execute: ( input: unknown, - context: { readonly sessionID: string; readonly callID: string }, + context: { readonly sessionID: string; readonly id: string }, ) => Effect.Effect<{ readonly structured: unknown readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }> @@ -403,7 +404,7 @@ it.effect("interrupts claimed calls when the controller scope closes", () => }), ) -it.effect("routes typed webfetch and websearch handlers independently", () => +it.effect("routes typed webfetch, websearch, and write handlers independently", () => Effect.scoped( Effect.gen(function* () { const controller = yield* ToolController.make((tools) => { @@ -413,13 +414,16 @@ it.effect("routes typed webfetch and websearch handlers independently", () => tools.handle("websearch", ({ input, index }) => Effect.succeed({ output: `${index}:${input.query}`, provider: "exa" }), ) + tools.handle("write", ({ input, index }) => + Effect.succeed({ output: `${index}:${input.path}:${input.content}` }), + ) }) const config: OpenCodeConfig = {} controller.configure(config) const injected = (config.plugins as Array<{ options: { endpoint: string; token: string; tools: string[] } }>)[0]! - expect(injected.options.tools).toEqual(["webfetch", "websearch"]) + expect(injected.options.tools).toEqual(["webfetch", "websearch", "write"]) const invoke = (name: string, input: unknown) => Effect.promise(async () => { @@ -449,6 +453,12 @@ it.effect("routes typed webfetch and websearch handlers independently", () => result: { output: "0:effect typescript", provider: "exa" }, }, ]) + expect(yield* invoke("write", { path: "src/a.ts", content: "export const a = 1\n" })).toEqual([ + { + type: "success", + result: { output: "0:src/a.ts:export const a = 1\n" }, + }, + ]) }), ), ) @@ -673,11 +683,11 @@ it.effect("notifies OpenCode when a registered background shell completes", () = yield* plugin.effect({ options, tool: { - transform: (register: (tools: { add: (name: string, tool: RegisteredTool) => void }) => void) => + transform: (register: (tools: { add: (tool: RegisteredTool) => void }) => void) => Effect.sync(() => register({ - add: (name, tool) => { - if (name === "shell") shell = tool + add: (tool) => { + if (tool.name === "shell") shell = tool }, }), ), @@ -690,10 +700,10 @@ it.effect("notifies OpenCode when a registered background shell completes", () = const started = yield* shell.execute( { command: "plugin", background: true }, - { sessionID: "ses_plugin", callID: "call_plugin" }, + { sessionID: "ses_plugin", id: "call_plugin" }, ) expect(started).toEqual({ - structured: { + output: { output: "The command was moved to the background.", shellID: "call_plugin", status: "running", @@ -869,11 +879,11 @@ it.effect("interrupts a handler with its plugin execution", () => yield* plugin.effect({ options, tool: { - transform: (register: (tools: { add: (name: string, tool: RegisteredTool) => void }) => void) => + transform: (register: (tools: { add: (tool: RegisteredTool) => void }) => void) => Effect.sync(() => register({ - add: (name, tool) => { - if (name === "shell") shell = tool + add: (tool) => { + if (tool.name === "shell") shell = tool }, }), ), @@ -886,7 +896,7 @@ it.effect("interrupts a handler with its plugin execution", () => const execution = yield* shell.execute( { command: "wait" }, - { sessionID: "ses_interrupt", callID: "call_interrupt" }, + { sessionID: "ses_interrupt", id: "call_interrupt" }, ).pipe(Effect.forkScoped) yield* Effect.promise(() => started.promise) yield* Fiber.interrupt(execution)