Repository navigation
feat(agents): experimental OpenCodeHarness and agents/models/opencode, with an example - #2513
Conversation
OpenCodeHarness hosts OpenCode v2 (@opencode/sdk/workerd) in a Durable Object with PiHarness's interface and lifecycle: a factory opens it, one Lifecycle wake job per session brings runs back after eviction, and operations are idempotent by id. - prefixTables (harness/table-prefix.ts) keeps an embedded engine's tables under a prefix in the object's SQLite; OpenCode's go under opencode_ until it has its own option (anomalyco/opencode#53577). - The harness closes OpenCode when idle: its background timers otherwise keep the object in memory. - agents/models/opencode serves Workers AI through the AI binding as an OpenCode plugin, reusing agents/models/ai-sdk's models via a V4->V3 adapter, with no global fetch patch.
… experimental - examples/next/harnesses/opencode: OpenCode on a Durable Object with OpenCodeHarness and agents/models/opencode, a React client over WebSockets, and a plugin that swaps OpenCode's local tools for notes. - session.history() returns the whole transcript; OpenCode compacts on its own, so messages() alone loses what the user saw. - busy() and sessions.list() count queued input and a turn a restart cut off, so a client that reconnects before OpenCode's background resume starts still follows the run. - OpenCodeSessionInfo carries OpenCode's generated title. - Both entry points are experimental; the user-facing docs page is removed until the API settles.
🦋 Changeset detectedLatest commit: 5665f5f The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| return <ToolCard key={key} part={part} />; | ||
| } | ||
| })} | ||
| {live?.text && !hasText ? <Markdown text={live.text} /> : null} |
There was a problem hiding this comment.
🟡 Live answer stalls after snapshot
When a snapshot catches an unfinished part, prune discards its live prefix. Later deltas start from empty, so partTexts hides them until their length exceeds the saved text.
Learn more
Snapshots carry saved text for each assistant part, while session.text.delta and session.reasoning.delta carry only new fragments. Once this filter removes an ordinal, the next fragment starts a fresh live string in the delta handler. partTexts selects by length, so the saved text hides new fragments until their length exceeds the snapshot; if it does, it displays the suffix without the saved prefix. This affects both text and reasoning while a message remains unfinished.
Example: A snapshot contains hello for text ordinal 0 and prunes its matching live text. The next delta is world; the UI continues showing hello instead of hello world. A longer delta would instead replace the visible prefix with only the new suffix.
Recommended fix: Keep a per-ordinal accumulated string until the assistant message completes, or store a snapshot baseline and append later deltas to it. Ensure prune and partTexts compare cumulative text rather than standalone fragments after pruning.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async fetch(request: Request, env: Env): Promise<Response> { | ||
| try { | ||
| return ( | ||
| (await routeAgentRequest(request, env, { cors: true })) ?? |
There was a problem hiding this comment.
🟥 Object identifier grants access to another browser's chats
When someone obtains another browser's object UUID, routeAgentRequest forwards their request without checking ownership. They can read that browser's sessions and notes, submit prompts, and abort runs.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (typeof message.text !== "string" || message.text.trim() === "") { | ||
| throw new Error("Nothing to send"); | ||
| } | ||
| this.#watch(session); | ||
| const receipt = await handle.submit(message.text, { | ||
| whenBusy: message.whenBusy === "steer" ? "steer" : "followUp" | ||
| }); |
There was a problem hiding this comment.
🟢 agents import sizes: 2 entry points changed, no growth
Changed exports (6)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
Harness and helpers:
- An idle session whose inbox starts with a move no longer re-wakes in a
loop; only inputs, synthetic messages and compactions are re-rung.
- prefixTables rewrites PRAGMA table_info('name') with a string argument.
- agents/models/opencode reloads the provider when a model's limit changes.
Example:
- Live text is merged per OpenCode ordinal with the saved transcript, so a
client that joins mid-answer keeps streaming.
- create() rejects when the socket closes or after 30s.
- Bad or unknown session ids get an unknown_session error, and the socket
takes no commands; the client falls back to the root session.
- Snapshots carry the whole history, and queued work counts as busy.
- Low reasoning effort, reasoning shown when it is the whole answer, the
info card, OpenCode's warnings in the Worker log, and a note on access.
… and quoted or schema-qualified PRAGMA names
# Conflicts: # examples/next/README.md # pnpm-lock.yaml
The multi-session crash test waits for three gated runs before crashing, long enough under CI load for the 1s heartbeat alarm to fire on its own, so runDurableObjectAlarm() can find nothing to run. The six gate runs that follow are what prove the recovery.
Adds an experimental
OpenCodeHarnessthat runs OpenCode v2 (@opencode/sdk/workerd) in a Durable Object behindPiHarness's interface, a Workers AI provider for OpenCode, and an example. Try it: https://opencode-harness-example.mattzcarey.workers.devagents/harness/opencodePiHarness:prompt/submit/wait/abort/messages/pending,sessions.create/fork/get/list,session.steer/setModel/busy/events. It addssetAgent,history()(the whole transcript, since OpenCode compacts on its own) andlog()(durable events, resumable by sequence number).operationIdmaps to OpenCode's user message idmsg_<operationId>, which OpenCode deduplicates.sequenceDiagram participant C as caller participant H as OpenCodeHarness participant J as Lifecycle job (per session) participant O as OpenCode C->>H: submit(input, { operationId }) H->>J: push wake (before OpenCode has the work) H->>O: sessions.prompt({ id: msg_<operationId> }) J->>O: sessions.wait() inside the alarm, heartbeat Note over O: eviction or crash mid-turn J-->>H: alarm restarts the object H->>O: OpenCodeWorkerd.create() resumes the turn J->>O: wait again until idlePiHarness. A step waits onsessions.wait()as alarm work and completes when the session is idle with an empty inbox. If the object died between OpenCode admitting an input and starting it, the step starts it again.busy()andsessions.list()also count queued input and a turn a restart cut off. OpenCode resumes that turn in the background on boot, so a client reconnecting before then still sees the session as busy.Table prefix (
src/harness/table-prefix.ts)OpenCode creates unprefixed tables (
session_v2,event,kv, …) and refuses to start if the database already has tables it doesn't own, such ascf_agents_*.prefixTables(storage, "opencode_")rewrites the engine's SQL by position (FROM/JOIN/INTO/TABLE/INDEX/REFERENCES/RENAME TO/ qualifiers), so a column named like a table is left alone. It also shows the engine asqlite_masterwith only its own objects. Nothing in it is OpenCode-specific. Upstream issue for a real option: anomalyco/opencode#53577.agents/models/opencodecreateAI({ binding })returns model references (ai("@cf/…") → { providerID: "cloudflare", id }) andai.plugin. The plugin registers a provider whoselanguagehook returnsagents/models/ai-sdk's model through a V4→V3 adapter (language-v3.ts). Nothing is patched globally.@ai-sdk/vercel, which OpenCode bundles, as its package. That gets OpenCode past thesdkstep, and thelanguagehook then replaces the model.Example:
examples/next/harnesses/opencodeWebSockets: transcript snapshots, live text and reasoning deltas merged per OpenCode ordinal (so a second tab joining mid-answer keeps streaming), tool cards, steer and stop, and a sessions sidebar with OpenCode's generated titles.sockets.tsruns onesession.events()watch per running session and stops it at idle, so the object can hibernate with sockets open.plugin.tsremoves OpenCode's file and shell tools, which have nothing to run on in Workers, and adds notes stored in the object's KV.Notes
WebSocketsonConnecthandler doesn't reach the client, because it runs before the upgrade completes. The example works around it; worth a separate fix.