Repository navigation
Harness opencode - #2483
Harness opencode#2483aron-cf wants to merge 3 commits into
Conversation
This uses OpenCode's embedded SDK (@opencode/sdk/workerd) in
a Durable Object as one Lifecycle capability. It has the same interface
as PiHarness.
The createAI({ binding }) from agents/models/opencode keeps OpenCode's
Workers AI provider and catalog, but uses the AI binding. Pass
ai.provider to OpenCodeHarness({ providers }).
The example has the same layout as the Pi harness example: the React client, the socket protocol on session.events(), the view and transcript models, Workspace tools from @cloudflare/computer, and the tests. It is built on agents/harness/opencode and agents/models/opencode. It also supports the OpenCode CLI. The object's onRequest serves OpenCode's HTTP API under /agents/open-code-agent/<name> (src/cli.ts). npx @opencode/cli@2 --server <origin>/agents/open-code-agent/<name> --session <ses_id>
🦋 Changeset detectedLatest commit: c8698ce 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 |
| // A step that called tools completes before the next step starts. | ||
| if ( | ||
| nextUser < 0 && | ||
| reply.content?.some( | ||
| (part) => | ||
| part.type === "tool" && | ||
| (part.state as { status?: unknown } | undefined)?.status !== | ||
| "completed" && | ||
| (part.state as { status?: unknown } | undefined)?.status !== "error" | ||
| ) | ||
| ) { | ||
| return "active"; |
There was a problem hiding this comment.
🔴 Tool turns return before final answers
After a tool finishes, inspectOperation accepts the completed tool-call step as the final reply. wait() can return empty text before the model produces its answer.
Learn more
An OpenCode turn can write several assistant messages: one completed step calls a tool, then a later step writes the final answer. inspectOperation checks only whether the last tool part is unfinished. Once the tool succeeds, that check fails, even though the turn continues. settled calls inspectOperation independently of the execution terminal event and returns immediately upon any result. This can also cause reconcile to close the operation stream early.
Example: A prompt asks multiply 7; the tool-call assistant message completes, its tool state becomes completed, and the next assistant message has not been written. wait() reports done with empty text rather than waiting for 21.
Recommended fix: Only classify a successful response as terminal using OpenCode's execution terminal marker or an explicit terminal assistant-step indicator. Do not infer completion solely from a completed assistant message or completed tool part; test the interval between tool completion and the next model step.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const existing = this.#writers.get(operationId); | ||
| if (existing) { | ||
| this.#current.set(session, operationId); | ||
| return existing; | ||
| } | ||
| const streamId = this.#streamId(operationId, session); | ||
| const writer = await this.#streams.open(streamId, { | ||
| tag: session, | ||
| metadata: { session, operationId } | ||
| }); | ||
| const operationWriter = new OperationStreamWriter({ | ||
| streamId, | ||
| operationId, | ||
| writer | ||
| }); | ||
| this.#writers.set(operationId, operationWriter); | ||
| this.#current.set(session, operationId); | ||
| return operationWriter; |
There was a problem hiding this comment.
🔴 Shared operation IDs mix session streams
When two sessions submit the same operationId, #openOperation reuses the first session's writer. Events enter the wrong stream, leaving the second operation without its own record.
Learn more
Operation ids are scoped to a session: streamId includes both identifiers, and enqueue checks for duplicates only within its session. The in-memory #writers map uses the operation id alone. On a collision, #openOperation returns the other session's writer without opening a stream for this session. Later projection and settlement can consume or close the wrong writer, while wait() checks the absent stream for the second session.
Example: Alice's session submits operationId: "op-1"; Bob's session also submits "op-1". Bob's operation is accepted, but its writer is Alice's and its stream never opens.
Recommended fix: Key #writers and settlement waiters by the session-and-operation composite, consistently across opening, projection, reconciliation, settlement, and forgetting.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async #root(): Promise<string> { | ||
| if (this.#rootSession) return this.#rootSession; | ||
| await this.lifecycle.ready(); | ||
| const stored = await this.lifecycle.storage.get<string>(ROOT_SESSION_KEY); | ||
| if (stored) { | ||
| this.#rootSession = stored; | ||
| await this.#readLog(stored); | ||
| return stored; | ||
| } | ||
| const host = await this.#host(); | ||
| const created = await host.sessions.create({}); | ||
| await this.#configure(created.id); | ||
| await this.lifecycle.storage.put(ROOT_SESSION_KEY, created.id); | ||
| this.#rootSession = created.id; | ||
| await this.#readLog(created.id); | ||
| return created.id; |
There was a problem hiding this comment.
🔴 Concurrent first requests split the root session
When first requests overlap, #root can create two sessions before either sets #rootSession. Different callers receive different roots, and the later write hides one session.
Learn more
Durable Objects can interleave requests at await points. Both callers see an unset #rootSession and absent stored id before awaiting sessions.create. Each writes its own id to ROOT_SESSION_KEY and returns it. Only the last value remains the root alias; the first caller can be submitting into a different session.
Example: Two browser sockets connect to a fresh object simultaneously. One resolves root to ses_A and the other to ses_B, although both asked for root; later connections see whichever id was written last.
Recommended fix: Memoize one in-flight root initialization promise before the first await, or atomically create and publish the root id under a storage transaction. Ensure failure clears the promise for retry.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async #openOperations(session: string): Promise<string[]> { | ||
| const rows = await this.#streams.list({ | ||
| tag: session, | ||
| state: "streaming" | ||
| }); |
There was a problem hiding this comment.
🔴 Older queued operations remain open after settlement
With over 50 open prompts in one session, #openOperations only sees the newest streams. Streams.list defaults to 50 rows, so older settled prompts remain open and retain the lease.
Learn more
The list method returns at most DEFAULT_LIST_LIMIT streams and sorts newest first. Both reconcile and release use #openOperations as if it contains every open prompt. Once a session accumulates more than 50 queued follow-ups, older streams can fall outside this page and never close; the lease keeps seeing open work.
Example: Submit 51 follow-ups before their responses settle. The oldest stream falls off the first page; after its answer appears, reconciliation never examines that stream.
Recommended fix: Page through all matching open streams, or query only this harness's streams using an explicit unbounded limit where bounded session volume is guaranteed. Cover more than 50 simultaneous pending operations in a test.
Was this helpful? React with 👍 or 👎 to provide feedback.
| this.#writers.set(operationId, operationWriter); | ||
| this.#current.set(session, operationId); | ||
| return operationWriter; |
There was a problem hiding this comment.
🔴 Queued follow-up steals the running turn
Submitting a follow-up while busy makes #openOperation replace #current before OpenCode delivers that prompt. Running-turn events enter the follow-up's stream, and its terminal event can settle the wrong operation.
Learn more
The current-operation map identifies the owner of live events and terminal execution events. enqueue opens the new operation before calling sessions.prompt; a busy followUp stays in the inbox until the current run ends. This assignment immediately replaces the owner anyway. settleTerminal then considers the follow-up the operation that just ended and can close it before it runs.
Example: Operation A is running a slow tool; operation B is queued. B replaces #current while A still runs, so A's later deltas belong to B and A's terminal event attempts to settle B.
Recommended fix: Open a stream without changing current ownership on submission. Switch #current only at delivery/start of that specific prompt; maintain the current owner independently of queued writers, including after an isolate restart.
Was this helpful? React with 👍 or 👎 to provide feedback.
| `NOTES.md` has the design decisions and everything that was hard or is still | ||
| missing. |
| options(): WebSocketsOptions { | ||
| return { | ||
| getConnectionTags: (_connection, ctx) => [ | ||
| sessionTag(sessionFromRequest(ctx.request, ROOT_SESSION)) | ||
| ], | ||
| handlers: { | ||
| onConnect: (connection, ctx) => this.#onConnect(connection, ctx), | ||
| onMessage: (connection, message) => | ||
| this.#onMessage(connection, message), | ||
| onClose: (connection) => this.#unwatch(connection), | ||
| onError: (connection) => this.#unwatch(connection) | ||
| } | ||
| }; |
There was a problem hiding this comment.
| "src/voice/tests/vitest.config.ts", | ||
| "src/voice/react-tests/vitest.config.ts", | ||
| "src/harness/pi/tests/vitest.config.ts", | ||
| "src/harness/opencode/tests/vitest.config.ts", |
| /** OpenCode's own HTTP API, for the OpenCode CLI. See `cli.ts`. */ | ||
| async onRequest(request: Request): Promise<Response> { | ||
| return this.harness.fetch(openCodeRequest(request)); | ||
| } |
There was a problem hiding this comment.
🟥 Public OpenCode API permits unauthorized session access
Anyone with an object URL can call harness.fetch without authentication. OpenCode's API exposes sessions and tool execution, allowing outsiders to read transcripts and operate the workspace.
Was this helpful? React with 👍 or 👎 to provide feedback.
| } | ||
| try { | ||
| return ( | ||
| (await routeAgentRequest(request, env, { cors: true })) ?? |
🟢 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: |
Remove comments that restate the code and tighten the layout left by comment removal. Use clearer names for the binding route and the in-flight operation, and explain the OpenCode-specific ordering and runtime constraints where names alone cannot carry the reason. Correct the example snippet so it uses this inside its class.
| export type OpenCodeHarnessOptions = { | ||
| readonly streams: Streams; | ||
| readonly providers?: readonly OpenCodeProvider[]; | ||
| readonly plugins?: readonly Plugin[]; | ||
| readonly config?: OpenCodeConfig; | ||
| readonly defaults?: OpenCodeSessionDefaults; | ||
| readonly workerd?: Omit< | ||
| OpenCodeWorkerd.CreateOptions, | ||
| "storage" | "config" | "plugins" | ||
| >; | ||
| readonly lease?: { | ||
| readonly ttlMs?: number; | ||
| readonly stallLimit?: number; | ||
| }; |
There was a problem hiding this comment.
| function clone(options: { | ||
| url: string; | ||
| dir?: string; | ||
| ref?: string; | ||
| depth?: number; | ||
| paths?: string[]; | ||
| singleBranch?: boolean; | ||
| noTags?: boolean; |
There was a problem hiding this comment.
🔍 Model-facing git instructions lose constraints
EXEC_DESCRIPTION is shown to the model. Removing its annotations hides the HTTPS-only clone constraint, clone defaults, and timestamp units. Restore constraints that help the model choose valid tool arguments.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
Thanks Aron. #2513 landed an
Not taken: the global |
…the example (#2568) * feat(agents): OpenCodeHarness session events, and Workspace tools in the example session.events() now returns { snapshot, start, stop } with OpenCodeSessionEvents, the same kinds of event PiHarness and ContainerHarness report, translated from OpenCode's typed events. OpenCode's raw events stay available from session.log(). The example replaces its notes plugin with the Workspace tools from @cloudflare/computer, offered to the model by name. Takes the event union and the Workspace tools from #2483 (Aron). * test(opencode): return the stream snapshot's transcript as texts over RPC * fix(opencode): idempotent event replay over the snapshot, no delivery after stop - An input delivered while events() reads the snapshot was both seeded from pending and replayed, so it ended twice. The projector now skips deliveries of inputs it already has or has ended. - The answer is reset when inputs settle and when a new run starts, so a run with no assistant step no longer reports the previous run's text. - start() after stop() delivers nothing, and stop() drops buffered events.
This uses OpenCode's embedded SDK (@opencode/sdk/workerd) in a Durable Object as one Lifecycle capability. It has the same interface as PiHarness.
The createAI({ binding }) from agents/models/opencode keeps OpenCode's Workers AI provider and catalog, but uses the AI binding. Pass ai.provider to OpenCodeHarness({ providers }).
The example has the same layout as the Pi harness example: the React client, the socket protocol on session.events(), the view and transcript models, Workspace tools from @cloudflare/computer, and the tests. It is built on agents/harness/opencode and agents/models/opencode.
It also supports the OpenCode CLI. The object's onRequest serves OpenCode's HTTP API under /agents/open-code-agent/ (src/cli.ts).