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/provider-tool-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": minor
---

Control arbitrary provider-backed tool lifecycles with dynamic registration, structured progress, success, failure, cancellation, and reconnect-safe replay.
60 changes: 60 additions & 0 deletions packages/drive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,66 @@ Declared `config` and `tuiConfig` values are deeply merged over fixture
`.opencode/opencode.jsonc` and `.opencode/tui.jsonc` files. Arrays replace
instead of merging, and mutations made in `setup` take final precedence.

Attach arbitrary provider-backed tools at runtime with their JSON schemas, then
take and settle native OpenCode invocations by model call ID. `attach` replaces
the complete dynamic set atomically; it does not affect the built-in adapters
configured through the driver or script `tools` option.

```ts
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"

export default OpenCodeDriver.use(({ tools, llm, ui }) =>
Effect.gen(function* () {
yield* tools.attach({
tools: [
{
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
outputSchema: {
type: "object",
properties: { answer: { type: "number" } },
required: ["answer"],
},
options: { codemode: false },
},
],
})
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "call_lookup",
name: "lookup",
input: { query: "meaning" },
}),
Llm.finish("tool-calls"),
)
yield* ui.submit("Look up the meaning")

const lookup = yield* tools.take("call_lookup")
yield* lookup.progress({
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
})
yield* lookup.finish({
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
}),
)
```

Drive owns progress sequence numbers and retries uncertain operations without
rerunning a claimed call. `awaitCancelled()` completes when OpenCode interrupts
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.

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
9 changes: 9 additions & 0 deletions packages/drive/compile/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export type ZeroConfigUseIsRunnable = Assert<
const controlledOptions: OpenCodeDriver.Options = { tools: ["shell"] }
declare const controls: Tool.Controls
const shellCalls = controls.control("shell")
declare const dynamicTools: Tool.AttachParams
const attached = controls.attach(dynamicTools)
const dynamicCall = controls.take("call_lookup")
declare const toolName: Tool.Name
controls.control(toolName)
export type ShellControlIsTyped = Assert<
Expand All @@ -63,6 +66,12 @@ export type ShellControlIsTyped = Assert<
Tool.ControlledCalls<Tool.ShellInput, Tool.ShellResult>
>
>
export type DynamicAttachIsTyped = Assert<
Equal<Effect.Success<typeof attached>, void>
>
export type DynamicCallIsTyped = Assert<
Equal<Effect.Success<typeof dynamicCall>, Tool.Invocation>
>
const controlled = OpenCodeDriver.use(controlledOptions, ({ tools }) =>
tools.control("shell").pipe(Effect.asVoid),
)
Expand Down
52 changes: 50 additions & 2 deletions packages/drive/docs/open-code-driver-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,53 @@ unresolved calls, and waits for transport cleanup. The callback-style
for fixed handlers; callback-controlled tools are not also available through
the runtime `tools.control` capability.

## Arbitrary tools use the provider-backed lifecycle

`tools.attach({ tools })` atomically replaces the complete dynamic registration
set for the current run. Registrations use OpenCode's canonical JSON Schema,
permission, namespace, and CodeMode options. Static `shell`, `webfetch`, and
`websearch` adapters remain installed separately.

```ts
yield* tools.attach({
tools: [
{
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
options: { codemode: false },
},
],
})

const invocation = yield* tools.take("call_lookup")
yield* invocation.progress({
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
})
yield* invocation.finish({
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
```

`take(callID)` matches `context.callID`, the model call ID supplied to
`Llm.toolCall`; `invocation.id` is the producer's transport identity. Drive
deduplicates invocation replay after a controller reconnect and retries
progress or terminal operations with the same producer identity and progress
sequence. `awaitCancelled()` observes OpenCode's native interruption. There is
no public cancel operation because cancellation flows from OpenCode to Drive.

Attaching a dynamic effective name that collides with a configured static
adapter fails locally. Calling `attach({ tools: [] })` clears the dynamic set.
Older OpenCode revisions remain compatible with static adapters and ordinary
LLM control; dynamic attachment fails with `Tool.LifecycleError` when the six
tool lifecycle capabilities are unavailable.

## LLM response description is separate from live LLM control

`Llm` is a pure data module. `llm` is the live capability that queues, sends, and serves responses.
Expand All @@ -262,8 +309,9 @@ yield* llm.queue(
Each constructor returns an ordinary serializable value. Raw values with the same schema remain accepted.

Tool calls remain atomic when options are omitted. Supplying stream options
serializes the input to JSON and emits it incrementally through the simulated
provider, producing OpenCode's normal tool-input lifecycle:
serializes the input to JSON and emits provider-neutral partial tool input when
the endpoint advertises that capability. Older endpoints retain the existing
OpenAI-compatible fallback:

```ts
Llm.toolCall(
Expand Down
53 changes: 39 additions & 14 deletions packages/drive/docs/open-code-driver-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ OpenCodeDriver
tuis additional frontend process factory
ui convenience alias for tui.ui
llm shared simulated-model control
tools runtime control for declared tool adapters
tools runtime control for static adapters and arbitrary tools
```

The names distinguish the two kinds of client involved:
Expand All @@ -25,8 +25,10 @@ The names distinguish the two kinds of client involved:
optional `recording`.
- `Tuis` launches and supervises additional frontend processes connected to
the same server.
- `tools` accepts independently controlled invocations for adapters declared
before OpenCode starts.
- `tools.control` accepts independently controlled invocations for adapters
declared before OpenCode starts.
- `tools.attach` and `tools.take` control arbitrary native tools through the
canonical provider-backed lifecycle.
- Transport-level JSON-RPC clients remain private implementation details.

`defineScript` consumes these exact capabilities. It adds a branded module
Expand All @@ -48,7 +50,9 @@ Effect Scope
controlled invocation exchanges
OpenCodeServer
backend simulation connection
reconnecting tool-only backend connection
LLM controller
dynamic ToolProducer
generated OpenCode SDK connection
TUI supervisor
primary TUI scope
Expand All @@ -59,9 +63,10 @@ Effect Scope

Library drivers create their ToolController before project preparation and
pass that controller into `OpenCodeInstance`. CLI scripts create the controller
inside `OpenCodeInstance`. Prepared drivers and script contexts derive controls
from their instance, so the controller that wrote the plugin configuration is
structurally the controller exposed at runtime.
inside `OpenCodeInstance`. Prepared drivers and script contexts combine the
instance's static controller with the server's dynamic producer. The static
controller that wrote plugin configuration remains the one exposed through
`tools.control`.

`OpenCodeDriver.make(options)` requires `Scope.Scope`. It returns once the
server, generated SDK client, primary TUI, and simulation connections are
Expand All @@ -73,11 +78,12 @@ settlement even when the user program fails.
Settlement is one shared terminal operation. It runs in this order:

1. Validate that queued LLM work was consumed.
2. Shut down the LLM controller.
3. Finish active recording timelines.
4. Close all TUI scopes and processes.
5. Export completed recordings.
6. Decode the schema-validated `RunReport`.
2. Validate that native dynamic-tool invocations were settled.
3. Shut down the LLM controller.
4. Finish active recording timelines.
5. Close all TUI scopes and processes.
6. Export completed recordings.
7. Decode the schema-validated `RunReport`.

`driver.settle()` is shared and idempotent. Once settlement starts, `tuis`
rejects new launches and `llm` rejects new responses. `OpenCodeDriver.use`
Expand All @@ -98,6 +104,24 @@ Terminal commitment uses a synchronous first-writer-wins Deferred completion;
Drive guarantees exactly-once acceptance inside the controller, not delivery
across a transport disconnect.

`ToolProducer` owns a separate backend socket because LLM chunks are not
idempotent and an LLM socket closure is terminal to `LlmController`. Dynamic
tool progress and terminal RPCs are idempotent by producer invocation ID and
sequence, so the tool-only connection may reconnect and replay pending
invocations safely. One ordered event stream preserves invocation-before-
cancellation order. The desired registration set survives reconnects and
manual server relaunches; invocation records are scoped to one server
generation because producer IDs may be reused by a new process.
Settlement first clears the dynamic registration set on OpenCode, then drains
the ordered local event stream before checking for unresolved invocations. The
clear acts as the server-side barrier that prevents a native invocation from
appearing after a successful settlement snapshot. Settlement is terminal for
dynamic attachment. Reconnects remain available while the clear is in flight;
the final connection gate drains any reconnect that landed during settlement
before preventing further backend creation. If the server generation has
already ended, its teardown has cleared the generation-scoped invocation
records, so settlement does not wait for a replacement backend.

## TUI Lifecycle

`Tuis.launch(options)` generates an internal identity. `Tuis.launch(name,
Expand Down Expand Up @@ -153,9 +177,10 @@ schema validation, request correlation, interruption, and connection failure.
The driver receives the connector through an Effect service and does not
expose it in userland.

The UI connection is request-response JSON-RPC. The backend additionally
receives unsolicited `llm.request` notifications and exposes them as a
validated stream to the LLM controller.
The UI connection is request-response JSON-RPC. The LLM backend additionally
receives unsolicited `llm.request` notifications. The tool-only backend keeps
ordered `tool.invocation` and `tool.cancel` notifications on one validated
stream and does not call `llm.attach`.

## Project Setup

Expand Down
2 changes: 1 addition & 1 deletion packages/drive/src/cli/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const runScript = Effect.fn("DriveCli.runScript")(function* (
kill: prepared.server.kill,
},
llm: prepared.llm,
tools: instance.tools,
tools: prepared.tools,
artifacts: instance.artifacts,
}
const primaryTui = prepared.primary
Expand Down
62 changes: 46 additions & 16 deletions packages/drive/src/driver/llm-responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,20 +116,11 @@ export const make = ({ requestTimeout }: Options): Responder => {
const delay = toolCall.options?.delay ?? 2
const chunkSize = toolCall.options?.chunkSize ?? 15
const chunks = [...chunkText(JSON.stringify(toolCall.input), chunkSize)]
for (let index = 0; index < chunks.length; index++) {
const text = chunks[index]
if (text === undefined) continue
const callDelta =
index === 0
? {
index: toolCall.index,
id: toolCall.id,
function: { name: toolCall.name, arguments: text },
}
: {
index: toolCall.index,
function: { arguments: text },
}
const providerNeutral = supportsCapability(
backend.compatibility,
"llm.tool-input-delta",
)
if (providerNeutral)
yield* call(
backend,
"llm.chunk",
Expand All @@ -138,12 +129,51 @@ export const make = ({ requestTimeout }: Options): Responder => {
id: requestId,
items: [
{
type: "raw",
chunk: { choices: [{ delta: { tool_calls: [callDelta] } }] },
type: "toolInputStart",
index: toolCall.index,
id: toolCall.id,
name: toolCall.name,
},
],
}),
)
for (let index = 0; index < chunks.length; index++) {
const text = chunks[index]
if (text === undefined) continue
const item = providerNeutral
? { type: "toolInputDelta" as const, index: toolCall.index, text }
: {
type: "raw" as const,
chunk: {
choices: [
{
delta: {
tool_calls: [
index === 0
? {
index: toolCall.index,
id: toolCall.id,
function: { name: toolCall.name, arguments: text },
}
: {
index: toolCall.index,
function: { arguments: text },
},
],
},
},
],
},
}
yield* call(
backend,
"llm.chunk",
requestId,
backend.rpc["llm.chunk"]({
id: requestId,
items: [item],
}),
)
if (index < chunks.length - 1 && delay > 0) yield* Effect.sleep(delay)
}
})
Expand Down
Loading