Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/control-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": patch
---

Restore controlled tools against the current V2 plugin API and add typed runtime control for write calls.
33 changes: 31 additions & 2 deletions packages/drive/src/tool/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
WebFetchResult,
WebSearchInput,
WebSearchResult,
WriteInput,
WriteResult,
type Configuration,
type ControlledCall,
type ControlledCalls,
Expand All @@ -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 }
Expand All @@ -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,
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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)
}),
})
}
}
}
Expand Down Expand Up @@ -217,6 +239,13 @@ export const make = Effect.fn("ToolController.make")(function* (configuration?:
closeControls.push(controlled.close)
break
}
case "write": {
const controlled = makeControlledHandler<WriteInput, WriteResult>("write")
handle("write", controlled.handler)
controlledCalls.write = controlled.calls
closeControls.push(controlled.close)
break
}
default:
throw new Error(`unsupported controlled tool: ${String(name)}`)
}
Expand Down
34 changes: 20 additions & 14 deletions packages/drive/src/tool/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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 })
Expand All @@ -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),
})
}
})
}),
Expand Down
15 changes: 14 additions & 1 deletion packages/drive/src/tool/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,23 @@ export const WebSearchResult = Schema.Struct({
})
export interface WebSearchResult extends Schema.Schema.Type<typeof WebSearchResult> {}

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<typeof WriteInput> {}

export const WriteResult = Schema.Struct({
output: Schema.String,
})
export interface WriteResult extends Schema.Schema.Type<typeof WriteResult> {}

export class Failure extends Schema.TaggedErrorClass<Failure>()(
"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)

Expand Down Expand Up @@ -102,11 +113,13 @@ export type Handler<Input, Result> = (
export type ShellHandler = Handler<ShellInput, ShellResult>
export type WebFetchHandler = Handler<WebFetchInput, WebFetchResult>
export type WebSearchHandler = Handler<WebSearchInput, WebSearchResult>
export type WriteHandler = Handler<WriteInput, WriteResult>

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<Tool extends Name> = Handler<
Expand Down
34 changes: 22 additions & 12 deletions packages/drive/test/tool/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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" },
},
])
}),
),
)
Expand Down Expand Up @@ -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
},
}),
),
Expand All @@ -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",
Expand Down Expand Up @@ -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
},
}),
),
Expand All @@ -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)
Expand Down