Skip to content
Open
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/dynamic-tool-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": patch
---

Ship runnable examples, including an arbitrary-tool lifecycle flow covering progress, cancellation, and terminal completion.
10 changes: 10 additions & 0 deletions packages/drive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,16 @@ the native invocation before `finish` or `fail`. Dynamic registrations survive
the tool-only controller reconnecting; an intentional server generation change
cancels unresolved calls and reapplies the desired set after launch.

Run the complete progress, cancellation, and terminal-completion example from
this repository with:

```sh
bun run --cwd packages/drive drive start --name dynamic-tool \
--script ./examples/dynamic-tool.ts
```

See [`examples/dynamic-tool.ts`](./examples/dynamic-tool.ts).

Declare which built-in tools Drive should intercept with `tools`, then control
their invocations inside `run`. Each tool controller accepts calls in arrival
order or by the stable call ID chosen in `Llm.toolCall`:
Expand Down
80 changes: 80 additions & 0 deletions packages/drive/examples/dynamic-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Effect, Schema, Stream } from "effect"
import { defineScript, Llm } from "opencode-drive"

const LookupInput = Schema.Struct({ query: Schema.String })

export default defineScript({
run: ({ tools, llm, ui }) => Effect.gen(function* () {
yield* tools.attach({
tools: [
{
name: "lookup",
description: "Look up a numeric value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
outputSchema: {
type: "object",
properties: { answer: { type: "number" } },
required: ["answer"],
additionalProperties: false,
},
options: { codemode: false },
},
],
})

let turn = 0
yield* llm.serve(() =>
turn++ === 0
? Stream.make(
Llm.toolCall({
index: 0,
id: "call_lookup",
name: "lookup",
input: { query: "meaning" },
}),
Llm.finish("tool-calls"),
)
: Stream.make(
Llm.text("The lookup returned 42."),
Llm.finish("stop"),
),
)

yield* ui.submit("Look up the meaning and report the result")
const lookup = yield* tools.take("call_lookup")
const input = yield* Schema.decodeUnknownEffect(LookupInput)(lookup.input)
const cancelled = lookup.awaitCancelled().pipe(
Effect.tap((cancellation) =>
Effect.log(`lookup cancelled: ${cancellation.reason}`),
),
Effect.as("cancelled" as const),
)
const outcome = yield* Effect.raceFirst(
Effect.gen(function* () {
yield* lookup.progress({
structured: { phase: "searching", query: input.query },
content: [{ type: "text", text: "Searching" }],
})
yield* Effect.sleep(250)
yield* lookup.finish({
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
yield* Effect.log("lookup completed")
return "completed" as const
}).pipe(
Effect.catch((error) =>
error.reason === "cancelled" ? cancelled : Effect.fail(error),
),
),
cancelled,
)
if (outcome === "completed")
yield* ui.waitFor("The lookup returned 42.")
}),
})
1 change: 1 addition & 0 deletions packages/drive/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"assets/fonts",
"bin/opencode-drive",
"docs",
"examples",
"src/cli",
"src/client",
"src/driver",
Expand Down
20 changes: 16 additions & 4 deletions packages/drive/src/tool/producer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { RpcClientError } from "effect/unstable/rpc"
import {
SimulationCompatibilityError,
SimulationConnectionError,
type BackendConnection,
type BackendConnection as SimulationBackendConnection,
} from "../simulation/connector.js"
import { Backend } from "../simulation/protocol.js"
import { SimulationRequestError } from "../simulation/rpc.js"
Expand All @@ -31,13 +31,25 @@ export interface BackendAttachment {
readonly detach: () => Effect.Effect<void>
}

export type BackendConnection = Pick<
SimulationBackendConnection,
| "endpoint"
| "toolEvents"
| "flushToolEvents"
| "closed"
| "attachTools"
| "updateTool"
| "finishTool"
| "failTool"
>

export interface Controller {
readonly controls: DynamicControls
readonly connect: (backend: BackendConnection) => Effect.Effect<BackendAttachment, LifecycleError>
readonly connectFrom: <E, R>(
backend: Effect.Effect<BackendConnection, E, R>,
readonly connectFrom: <B extends BackendConnection, E, R>(
backend: Effect.Effect<B, E, R>,
) => Effect.Effect<
{ readonly backend: BackendConnection; readonly attachment: BackendAttachment },
{ readonly backend: B; readonly attachment: BackendAttachment },
E | LifecycleError,
R
>
Expand Down
Loading