diff --git a/.changeset/wise-bridges-resume.md b/.changeset/wise-bridges-resume.md new file mode 100644 index 0000000..5b5f4e0 --- /dev/null +++ b/.changeset/wise-bridges-resume.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-provider-cli-bridge": minor +--- + +Make CLI bridge turns resumable, idempotent, and cancellation-safe using server-owned run state, and derive bridge selection from run data. diff --git a/packages/agent-provider-cli-bridge/README.md b/packages/agent-provider-cli-bridge/README.md index b2205c5..424ef0e 100644 --- a/packages/agent-provider-cli-bridge/README.md +++ b/packages/agent-provider-cli-bridge/README.md @@ -8,9 +8,23 @@ import { createCliBridgeProvider } from '@tangle-network/agent-provider-cli-brid const provider = createCliBridgeProvider({ baseUrl: 'http://127.0.0.1:8787', bearerToken: process.env.CLI_BRIDGE_TOKEN, - defaultModel: 'codex', +}) + +const environment = await provider.create({ + profile: { + name: 'researcher', + harness: 'codex', + model: { default: 'gpt-5' }, + }, }) ``` +The bridge model is selected from run data in this order: the turn, the provider default, or the profile's `harness` plus `model.default`. +Execution fails before network use when none is present. + +Passing the same `sessionId` on later turns continues the same CLI conversation. +`executionId` gives a turn stable bridge identity, and `lastEventId` reattaches after a reader failure. +Stopping a reader or destroying the environment cancels every active bridge run and waits for terminal confirmation. + Response headers and streamed bodies have no transport timeout by default. For unattended runs, set `headersTimeoutMs`, `bodyTimeoutMs`, or an `AbortSignal` so an unresponsive bridge cannot wait forever. diff --git a/packages/agent-provider-cli-bridge/src/index.test.ts b/packages/agent-provider-cli-bridge/src/index.test.ts index 5ff6339..4637b61 100644 --- a/packages/agent-provider-cli-bridge/src/index.test.ts +++ b/packages/agent-provider-cli-bridge/src/index.test.ts @@ -87,6 +87,7 @@ describe("createCliBridgeProvider", () => { it("throws after surfacing a bridge error", async () => { const provider = createCliBridgeProvider({ baseUrl: "http://bridge.local", + defaultModel: "opencode", fetch: async () => new Response('data: {"error":{"message":"harness failed"}}\n\n', { status: 200, @@ -105,6 +106,7 @@ describe("createCliBridgeProvider", () => { it("rejects a stream that ends without a terminal result", async () => { const provider = createCliBridgeProvider({ baseUrl: "http://bridge.local", + defaultModel: "opencode", fetch: async () => new Response('data: {"choices":[{"delta":{"content":"partial"}}]}\n\ndata: [DONE]\n\n', { status: 200, @@ -145,6 +147,7 @@ describe("createCliBridgeProvider", () => { try { const provider = createCliBridgeProvider({ baseUrl: `http://127.0.0.1:${address.port}`, + defaultModel: "opencode", }); environment = await provider.create({ profile: { name: "worker" } }); for (let turn = 0; turn < 2; turn += 1) { @@ -158,11 +161,15 @@ describe("createCliBridgeProvider", () => { } expect(connectionCount).toBe(1); + const lazy = environment.stream({ prompt: "too late" }); await environment.destroy?.(); await new Promise((resolve) => setTimeout(resolve, 25)); expect(sockets.size).toBe(0); await expect(environment.status()).resolves.toBe("stopped"); - expect(() => environment?.stream({ prompt: "too late" })).toThrow("cli-bridge environment is destroyed"); + await expect(consumeEvents(lazy)).rejects.toThrow( + "cli-bridge environment is destroyed", + ); + expect(connectionCount).toBe(1); await expect(environment.destroy?.()).resolves.toBeUndefined(); } finally { await environment?.destroy?.(); @@ -174,7 +181,19 @@ describe("createCliBridgeProvider", () => { it("enforces a configured response-header timeout", async () => { let delayedResponse: ReturnType | undefined; - const server = createServer((_request, response) => { + const server = createServer((request, response) => { + if (request.url?.endsWith("/cancel")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + '{"cancelled":true,"cancel_requested":true,"terminal":true,"run":{"id":"timeout-run","status":"cancelled","terminal":true}}', + ); + return; + } + if (request.url?.startsWith("/v1/runs/")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"id":"timeout-run","status":"running","terminal":false}'); + return; + } delayedResponse = setTimeout(() => { response.writeHead(200, { "content-type": "text/event-stream" }); response.end( @@ -188,6 +207,7 @@ describe("createCliBridgeProvider", () => { const provider = createCliBridgeProvider({ baseUrl: `http://127.0.0.1:${address.port}`, + defaultModel: "opencode", headersTimeoutMs: 10, }); const environment = await provider.create({ profile: { name: "worker" } }); @@ -206,7 +226,19 @@ describe("createCliBridgeProvider", () => { it("enforces a configured response-body idle timeout", async () => { let delayedBody: ReturnType | undefined; - const server = createServer((_request, response) => { + const server = createServer((request, response) => { + if (request.url?.endsWith("/cancel")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + '{"cancelled":true,"cancel_requested":true,"terminal":true,"run":{"id":"timeout-run","status":"cancelled","terminal":true}}', + ); + return; + } + if (request.url?.startsWith("/v1/runs/")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"id":"timeout-run","status":"running","terminal":false}'); + return; + } response.writeHead(200, { "content-type": "text/event-stream" }); response.write('data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}\n\n'); delayedBody = setTimeout(() => { @@ -221,6 +253,7 @@ describe("createCliBridgeProvider", () => { const provider = createCliBridgeProvider({ baseUrl: `http://127.0.0.1:${address.port}`, + defaultModel: "opencode", bodyTimeoutMs: 10, }); const environment = await provider.create({ profile: { name: "worker" } }); @@ -238,7 +271,7 @@ describe("createCliBridgeProvider", () => { }); it.each( - (["headersTimeoutMs", "bodyTimeoutMs"] as const).flatMap((name) => + (["headersTimeoutMs", "bodyTimeoutMs", "cancelWaitMs"] as const).flatMap((name) => [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY].map((value) => ({ name, value })), ), )("rejects invalid $name=$value before execution", ({ name, value }) => { @@ -249,6 +282,440 @@ describe("createCliBridgeProvider", () => { }), ).toThrow(`${name} must be a non-negative integer`); }); + + it("continues one bridge-owned session with profile-selected harness, provider, and model", async () => { + const bodies: Array> = []; + const answers = new Map(); + let lastRunId = ""; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + fetch: async (url, init) => { + if (init?.method === "GET") { + return runResponse(lastRunId, "done", true); + } + const body = JSON.parse(String(init?.body)) as Record; + const runId = String(body.run_id); + lastRunId = runId; + let answer = answers.get(runId); + if (!answer) { + bodies.push(body); + answer = `answer-${bodies.length}`; + answers.set(runId, answer); + } + return new Response( + [ + `id: 1\ndata: {"choices":[{"delta":{"content":"${answer}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1}}\n\n`, + "data: [DONE]\n\n", + ].join(""), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const environment = await provider.create({ + profile: { + name: "scientist", + harness: "pi", + model: { provider: "tangle-router", default: "glm-5.2" }, + }, + }); + + await consumeTurn(environment, { + prompt: "first", + sessionId: "session-1", + model: "glm-5.2", + turnId: "turn-1", + executionId: "run-1", + }); + await consumeTurn(environment, { + prompt: "second", + sessionId: "session-1", + model: "tangle-router/glm-5.2", + turnId: "turn-2", + executionId: "run-2", + }); + expect(bodies).toHaveLength(2); + expect(bodies).toEqual([ + expect.objectContaining({ + model: "pi/tangle-router/glm-5.2", + session_id: "session-1", + run_id: "run-1", + }), + expect.objectContaining({ + model: "pi/tangle-router/glm-5.2", + session_id: "session-1", + run_id: "run-2", + }), + ]); + expect(provider.capabilities()).toMatchObject({ streaming: { replay: true } }); + }); + + it("waits through a 202 cancellation when a caller stops reading", async () => { + let status: "running" | "cancelled" = "running"; + let getCalls = 0; + const requested: string[] = []; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "pi/tangle-router/glm-5.2", + fetch: async (url, init) => { + requested.push(String(url)); + if (init?.method === "GET") { + getCalls += 1; + if (getCalls === 1) { + return runResponse("run-reader-stop", "running", false); + } + status = "cancelled"; + return runResponse("run-reader-stop", status, true); + } + if (String(url).endsWith("/cancel")) { + return cancelResponse("run-reader-stop", "running", false, 202); + } + return new Response( + [ + 'id: 1\ndata: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}\n\n', + 'id: 2\ndata: {"choices":[{"delta":{"content":"unused"},"finish_reason":"stop"}]}\n\n', + ].join(""), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + const iterator = environment.stream({ + prompt: "work", + sessionId: "reader-stop", + executionId: "run-reader-stop", + })[Symbol.asyncIterator](); + + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: "message.part.updated", id: "1" }, + }); + await iterator.return?.(); + + expect(requested).toContain("http://bridge.local/v1/runs/run-reader-stop/cancel"); + expect(requested).toContain( + "http://bridge.local/v1/runs/run-reader-stop?wait_ms=30000", + ); + expect(getCalls).toBe(2); + }); + + it("keeps an environment retryable when cancellation is not confirmed", async () => { + let startedResolve!: () => void; + const started = new Promise((resolve) => { + startedResolve = resolve; + }); + let cancelCalls = 0; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "runner/model", + fetch: async (url, init) => { + if (String(url).endsWith("/cancel")) { + cancelCalls += 1; + if (cancelCalls === 1) { + return new Response('{"error":{"message":"temporary failure"}}', { + status: 503, + }); + } + return cancelResponse("retryable-run", "cancelled", true); + } + if (init?.method === "GET") { + return runResponse("retryable-run", "running", false); + } + startedResolve(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + const running = consumeTurn(environment, { + prompt: "long work", + executionId: "retryable-run", + }); + await started; + + await expect(environment.destroy?.()).rejects.toThrow("cli-bridge cancel 503"); + await expect(environment.status()).resolves.toBe("running"); + await environment.destroy?.(); + await expect(running).rejects.toThrow("cli-bridge run ended cancelled"); + expect(cancelCalls).toBe(2); + }); + + it("cancels an active sessionless run before destroying its transport", async () => { + let startedResolve!: () => void; + const started = new Promise((resolve) => { + startedResolve = resolve; + }); + const requested: string[] = []; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "pi/tangle-router/glm-5.2", + fetch: async (url, init) => { + requested.push(String(url)); + if (String(url).endsWith("/cancel")) { + return cancelResponse("run-no-session", "cancelled", true); + } + if (init?.method === "GET") { + return runResponse("run-no-session", "cancelled", true); + } + startedResolve(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + const running = consumeTurn(environment, { + prompt: "long work", + executionId: "run-no-session", + }); + await started; + + await environment.destroy?.(); + await expect(running).rejects.toThrow("cli-bridge run ended cancelled"); + expect(requested).toContain("http://bridge.local/v1/runs/run-no-session/cancel"); + }); + + it("isolates derived run ids across environments and keeps them wire-safe", async () => { + const runIds: string[] = []; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "pi/tangle-router/glm-5.2", + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as Record; + runIds.push(String(body.run_id)); + return terminalResponse("ok"); + }, + }); + const first = await provider.create({ profile: { name: "same-name" } }); + const second = await provider.create({ profile: { name: "same-name" } }); + + await consumeTurn(first, { prompt: "same", turnId: "turn-1" }); + await consumeTurn(second, { prompt: "same", turnId: "turn-1" }); + + expect(runIds).toHaveLength(2); + expect(runIds[0]).not.toBe(runIds[1]); + for (const runId of runIds) { + expect(runId.length).toBeLessThanOrEqual(128); + expect(runId).toMatch(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u); + } + }); + + it("hashes an unsafe execution id deterministically", async () => { + const runIds: string[] = []; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "pi/tangle-router/glm-5.2", + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as Record; + runIds.push(String(body.run_id)); + return terminalResponse("ok"); + }, + }); + const environment = await provider.create({ + profile: { name: "worker" }, + idempotencyKey: "environment-1", + }); + const unsafe = `${"not/wire safe ".repeat(20)}!`; + + await consumeTurn(environment, { prompt: "same", executionId: unsafe }); + await consumeTurn(environment, { prompt: "same", executionId: unsafe }); + + expect(runIds[0]).toBe(runIds[1]); + expect(runIds[0]).toMatch(/^agent-[a-f0-9]{64}$/u); + }); + + it("reattaches after a reader failure using the server event cursor", async () => { + let chatCalls = 0; + let aggregateCalls = 0; + let status: "running" | "done" = "running"; + let replayCursor: string | null = null; + const encoder = new TextEncoder(); + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "pi/tangle-router/glm-5.2", + fetch: async (_url, init) => { + if (init?.method === "GET") return runResponse("run-replay", status, status === "done"); + const body = JSON.parse(String(init?.body)) as Record; + if (body.stream === false) { + aggregateCalls += 1; + return Response.json({ + choices: [{ + message: { role: "assistant", content: "partial complete" }, + finish_reason: "stop", + }], + }); + } + chatCalls += 1; + if (chatCalls === 1) { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'id: 1\ndata: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}\n\n', + ), + ); + controller.error(new Error("reader disconnected")); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + } + replayCursor = new Headers(init?.headers).get("last-event-id"); + status = "done"; + return new Response( + 'id: 2\ndata: {"choices":[{"delta":{"content":" complete"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + + await expect( + consumeTurn(environment, { + prompt: "work", + sessionId: "replay", + executionId: "run-replay", + }), + ).rejects.toThrow("reader disconnected"); + const replayed = []; + for await (const event of environment.stream({ + prompt: "work", + sessionId: "replay", + executionId: "run-replay", + lastEventId: "1", + })) replayed.push(event); + + expect(replayCursor).toBe("1"); + expect(replayed.at(-1)).toMatchObject({ + type: "result", + id: "2", + data: { finalText: "partial complete" }, + }); + expect(aggregateCalls).toBe(1); + }); + + it("reads the full result when replay starts after the terminal event", async () => { + let aggregateCalls = 0; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "runner/model", + fetch: async (_url, init) => { + const body = JSON.parse(String(init?.body)) as Record; + if (body.stream === false) { + aggregateCalls += 1; + return Response.json({ + choices: [{ + message: { role: "assistant", content: "already complete" }, + finish_reason: "stop", + }], + }); + } + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + const events = []; + + for await (const event of environment.stream({ + prompt: "work", + executionId: "terminal-replay", + lastEventId: "3", + })) events.push(event); + + expect(events).toEqual([{ + type: "result", + id: "3", + data: { + finalText: "already complete", + finishReason: "stop", + status: "completed", + }, + }]); + expect(aggregateCalls).toBe(1); + }); + + it("does not claim or cancel a run id rejected by the bridge", async () => { + let cancelCalls = 0; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: "runner/model", + fetch: async (url, init) => { + if (String(url).endsWith("/cancel")) { + cancelCalls += 1; + return cancelResponse("shared-run", "cancelled", true); + } + if (init?.method === "GET") { + return runResponse("shared-run", "running", false); + } + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("response body failed")); + }, + }), + { status: 409 }, + ); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + + await expect(consumeTurn(environment, { + prompt: "conflicting work", + executionId: "shared-run", + })).rejects.toThrow("cli-bridge 409"); + await environment.destroy?.(); + + expect(cancelCalls).toBe(0); + }); + + it("refuses execution when no run data selects a model or harness", async () => { + let called = false; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + fetch: async () => { + called = true; + return new Response(); + }, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + + await expect(consume(environment)).rejects.toThrow( + "requires an explicit bridge model or a profile/backend harness", + ); + expect(called).toBe(false); + }); + + it("uses a provider-qualified turn model instead of the profile provider", async () => { + let body: Record | undefined; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + fetch: async (_url, init) => { + body = JSON.parse(String(init?.body)) as Record; + return terminalResponse("ok"); + }, + }); + const environment = await provider.create({ + backend: "runner", + profile: { + name: "worker", + model: { provider: "preferred", default: "base" }, + }, + }); + + await consumeTurn(environment, { + prompt: "work", + model: "override/model", + }); + + expect(body?.model).toBe("runner/override/model"); + }); }); async function consume(environment: AgentEnvironment): Promise { @@ -256,3 +723,56 @@ async function consume(environment: AgentEnvironment): Promise { // Drain the stream to its terminal condition. } } + +async function consumeTurn( + environment: AgentEnvironment, + turn: Parameters[0], +): Promise { + await consumeEvents(environment.stream(turn)); +} + +async function consumeEvents( + events: AsyncIterable, +): Promise { + for await (const _event of events) { + // Drain the stream to its terminal condition. + } +} + +function terminalResponse(text: string): Response { + return new Response( + `data: {"choices":[{"delta":{"content":"${text}"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +function runResponse( + id: string, + status: "running" | "done" | "error" | "cancelled", + terminal: boolean, +): Response { + return new Response(JSON.stringify({ id, status, terminal }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function cancelResponse( + id: string, + status: "running" | "done" | "error" | "cancelled", + terminal: boolean, + responseStatus = 200, +): Response { + return new Response( + JSON.stringify({ + cancelled: status === "cancelled", + cancel_requested: true, + terminal, + run: { id, status, terminal }, + }), + { + status: responseStatus, + headers: { "content-type": "application/json" }, + }, + ); +} diff --git a/packages/agent-provider-cli-bridge/src/index.ts b/packages/agent-provider-cli-bridge/src/index.ts index 386cc99..5868211 100644 --- a/packages/agent-provider-cli-bridge/src/index.ts +++ b/packages/agent-provider-cli-bridge/src/index.ts @@ -1,9 +1,9 @@ +import { createHash } from "node:crypto"; import type { AgentEnvironment, AgentEnvironmentCapabilities, AgentEnvironmentEvent, AgentEnvironmentProvider, - AgentEnvironmentStatus, AgentProfileRef, AgentTurnInput, CreateAgentEnvironmentInput, @@ -34,6 +34,8 @@ export interface CliBridgeProviderOptions { headersTimeoutMs?: number; /** Maximum idle time between response body chunks. Defaults to no timeout. */ bodyTimeoutMs?: number; + /** Maximum wait for cli-bridge to confirm cancellation. Defaults to 30 seconds. */ + cancelWaitMs?: number; fetch?: typeof fetch; name?: string; capabilities?: AgentEnvironmentCapabilities; @@ -42,80 +44,310 @@ export interface CliBridgeProviderOptions { export function createCliBridgeProvider(options: CliBridgeProviderOptions): AgentEnvironmentProvider { assertTimeout(options.headersTimeoutMs, "headersTimeoutMs"); assertTimeout(options.bodyTimeoutMs, "bodyTimeoutMs"); + assertTimeout(options.cancelWaitMs, "cancelWaitMs"); const name = options.name ?? "cli-bridge"; return { name, capabilities: () => options.capabilities ?? defaultCliBridgeCapabilities(), async create(input) { const transport = createTransport(options); + const environmentId = input.idempotencyKey ?? crypto.randomUUID(); + const runs = new Map(); + const readers = new Set(); let destroyed = false; let closePromise: Promise | undefined; - return { - id: input.idempotencyKey ?? input.name ?? crypto.randomUUID(), + const stream = async function* ( + turn: AgentTurnInput, + ): AsyncIterable { + if (destroyed) throw new Error("cli-bridge environment is destroyed"); + yield* streamTrackedCliBridgeTurn( + options, + input, + turn, + transport, + environmentId, + runs, + readers, + ); + }; + const environment = { + id: environmentId, provider: name, ...(input.name ? { name: input.name } : {}), status: async () => (destroyed ? "stopped" : "running"), - stream: (turn) => { - if (destroyed) throw new Error("cli-bridge environment is destroyed"); - return streamCliBridgeTurn(options, input, turn, transport); - }, + stream, placement: async () => ({ kind: options.defaultExecution?.kind === "sandbox" ? "sandbox" : "local", providerMetadata: { baseUrl: options.baseUrl }, }), - destroy: () => { + async destroy() { + if (closePromise) return closePromise; destroyed = true; - closePromise ??= transport.close(); - return closePromise; + let cancellationsConfirmed = false; + const attempt = (async () => { + const cancellations = await Promise.allSettled( + Array.from(runs.values()).map(async (run) => { + const snapshot = await cancelCliBridgeRun(options, transport, run); + if (runs.get(run.id) === run) runs.delete(run.id); + return snapshot; + }), + ); + const failures = cancellations.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, "cli-bridge environment cancellation failed"); + } + cancellationsConfirmed = true; + for (const reader of readers) { + reader.abort( + new DOMException("cli-bridge environment was destroyed", "AbortError"), + ); + } + await transport.close(); + })(); + closePromise = attempt; + try { + await attempt; + } catch (error) { + closePromise = undefined; + if (!cancellationsConfirmed) destroyed = false; + throw error; + } }, } satisfies AgentEnvironment; + return environment; }, }; } -async function* streamCliBridgeTurn( +interface CliBridgeRun { + readonly id: string; + readonly readers: Set; + cancellation?: Promise; +} + +interface CliBridgeRunSnapshot { + readonly id: string; + readonly status: "running" | "done" | "error" | "cancelled"; + readonly terminal: boolean; +} + +async function* streamTrackedCliBridgeTurn( options: CliBridgeProviderOptions, environmentInput: CreateAgentEnvironmentInput, + originalTurn: AgentTurnInput, + transport: CliBridgeTransport, + environmentId: string, + runs: Map, + readers: Set, +): AsyncIterable { + if (originalTurn.detach) { + throw new Error("cli-bridge provider does not support detached turns"); + } + const sessionId = originalTurn.sessionId; + const turnId = originalTurn.turnId ?? crypto.randomUUID(); + const runId = cliBridgeRunId(environmentId, originalTurn, turnId); + const controller = new AbortController(); + const signals = [ + originalTurn.signal, + environmentInput.signal, + controller.signal, + ].filter((signal): signal is AbortSignal => signal !== undefined); + const turn = { + ...originalTurn, + turnId, + ...(signals.length > 0 ? { signal: AbortSignal.any(signals) } : {}), + }; + const requestBody = JSON.stringify( + toChatCompletionsBody(options, environmentInput, turn, runId), + ); + const run = runs.get(runId) ?? { + id: runId, + readers: new Set(), + }; + run.readers.add(controller); + readers.add(controller); + runs.set(runId, run); + + let drained = false; + let threw = false; + try { + for await (const event of streamCliBridgeTurn( + options, + turn, + requestBody, + transport, + runId, + originalTurn.lastEventId, + signals.length > 0 ? AbortSignal.any(signals) : undefined, + )) { + yield event; + } + drained = true; + if (runs.get(run.id) === run) runs.delete(run.id); + } catch (error) { + threw = true; + if (error instanceof CliBridgeRequestRejectedError) { + if (runs.get(run.id) === run) runs.delete(run.id); + throw error; + } + let snapshot: CliBridgeRunSnapshot | null | undefined; + try { + snapshot = await getCliBridgeRun(options, transport, run.id); + } catch { + snapshot = undefined; + } + if (snapshot?.terminal) { + if (runs.get(run.id) === run) runs.delete(run.id); + } else if ( + (originalTurn.signal?.aborted || environmentInput.signal?.aborted) + ) { + await cancelCliBridgeRun(options, transport, run); + if (runs.get(run.id) === run) runs.delete(run.id); + } + throw error; + } finally { + if (!drained && !threw && runs.get(run.id) === run) { + await cancelCliBridgeRun(options, transport, run); + runs.delete(run.id); + } + run.readers.delete(controller); + readers.delete(controller); + } +} + +async function cancelCliBridgeRun( + options: CliBridgeProviderOptions, + transport: CliBridgeTransport, + run: CliBridgeRun, +): Promise { + if (run.cancellation) return run.cancellation; + run.cancellation = (async () => { + const response = await transport.fetch( + `${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(run.id)}/cancel`, + { + method: "POST", + headers: requestHeaders(options), + body: "{}", + }, + ); + if (!response.ok) { + throw new Error(`cli-bridge cancel ${response.status}: ${await response.text()}`); + } + let snapshot: CliBridgeRunSnapshot | null = cancelSnapshot(await response.text()); + const waitBudgetMs = options.cancelWaitMs ?? 30_000; + const deadline = Date.now() + waitBudgetMs; + while (!snapshot.terminal) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + snapshot = await getCliBridgeRun( + options, + transport, + run.id, + Math.min(remainingMs, 30_000), + ); + if (snapshot === null) { + throw new Error(`cli-bridge lost run "${run.id}" before confirming cancellation`); + } + } + if (!snapshot?.terminal) { + throw new Error(`cli-bridge run "${run.id}" did not confirm terminal cancellation`); + } + for (const reader of run.readers) { + reader.abort( + new DOMException(`cli-bridge run ended ${snapshot.status}`, "AbortError"), + ); + } + return snapshot; + })(); + try { + return await run.cancellation; + } finally { + run.cancellation = undefined; + } +} + +async function getCliBridgeRun( + options: CliBridgeProviderOptions, + transport: CliBridgeTransport, + runId: string, + waitMs?: number, +): Promise { + const query = waitMs === undefined ? "" : `?wait_ms=${waitMs}`; + const response = await transport.fetch( + `${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(runId)}${query}`, + { + method: "GET", + headers: requestHeaders(options), + }, + ); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`cli-bridge run status ${response.status}: ${await response.text()}`); + } + return runSnapshot(await response.text()); +} + +async function* streamCliBridgeTurn( + options: CliBridgeProviderOptions, turn: AgentTurnInput, + requestBody: string, transport: CliBridgeTransport, + runId: string, + lastEventId?: string, + signal?: AbortSignal, ): AsyncIterable { const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, { method: "POST", headers: { - "content-type": "application/json", + ...requestHeaders(options), accept: "text/event-stream", - ...(options.bearerToken ? { authorization: `Bearer ${options.bearerToken}` } : {}), ...(turn.sessionId ? { "x-session-id": turn.sessionId } : {}), + ...(lastEventId ? { "last-event-id": lastEventId } : {}), }, - body: JSON.stringify(toChatCompletionsBody(options, environmentInput, turn)), - signal: turn.signal ?? environmentInput.signal, + body: requestBody, + signal, }); if (!response.ok) { - throw new Error(`cli-bridge ${response.status}: ${await response.text()}`); + let detail = "request rejected"; + try { + detail = await response.text(); + } catch { + // The HTTP status already proves this request was rejected. + } + throw new CliBridgeRequestRejectedError(response.status, detail); } if (!response.body) throw new Error("cli-bridge response body is empty"); let text = ""; - const sessionId = turn.sessionId ?? environmentInput.idempotencyKey ?? environmentInput.name ?? "cli-bridge"; + const sessionId = turn.sessionId ?? runId; const messageId = turn.turnId ?? `${sessionId}:assistant`; const emittedToolCalls = new Set(); let completed = false; - for await (const event of parseSse(response.body)) { - if (event === "[DONE]") continue; - const parsed = safeJson(event); + let terminalCursor: string | undefined; + for await (const frame of parseSse(response.body)) { + if (frame.data === "[DONE]") continue; + const parsed = safeJson(frame.data); if (!parsed) continue; if (parsed.error && typeof parsed.error === "object") { const error = parsed.error as Record; const message = typeof error.message === "string" ? error.message : "cli-bridge error"; - yield { type: "status", data: { status: "failed", error: message } }; + yield { + type: "status", + data: { status: "failed", error: message }, + ...(frame.id ? { id: frame.id } : {}), + }; throw new Error(`cli-bridge: ${message}`); } const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : undefined; const delta = choice?.delta; const chunk = delta && typeof delta.content === "string" ? delta.content : ""; const nextUsage = usageFromOpenAi(parsed.usage); + const frameEvents: AgentEnvironmentEvent[] = []; if (nextUsage) { - yield { type: "usage", data: {}, usage: nextUsage }; + frameEvents.push({ type: "usage", data: {}, usage: nextUsage }); } if (chunk) { text += chunk; @@ -131,11 +363,11 @@ async function* streamCliBridgeTurn( part, delta: chunk, }; - yield { + frameEvents.push({ type: "message.part.updated", data: { part, delta: chunk }, normalized, - }; + }); } for (const toolCall of toolCallsFromDelta(delta)) { const callId = toolCall.id ?? `${messageId}:tool:${toolCall.index}`; @@ -154,32 +386,108 @@ async function* streamCliBridgeTurn( type: "message.part.updated", part, }; - yield { + frameEvents.push({ type: "message.part.updated", data: { part }, normalized, - }; + }); } if (choice?.finish_reason) { if (choice.finish_reason === "error") { - yield { + frameEvents.push({ type: "status", data: { status: "failed", error: "cli-bridge returned finish_reason=error" }, - }; + }); + yield* eventsWithCursor(frameEvents, frame.id); throw new Error("cli-bridge returned finish_reason=error"); } completed = true; - yield { - type: "result", - data: { - finalText: text, - finishReason: choice.finish_reason, - status: "completed", - }, - }; + if (lastEventId) { + terminalCursor = frame.id; + } else { + frameEvents.push({ + type: "result", + data: { + finalText: text, + finishReason: choice.finish_reason, + status: "completed", + }, + }); + } } + yield* eventsWithCursor( + frameEvents, + choice?.finish_reason && lastEventId ? undefined : frame.id, + ); + } + if (!completed && !lastEventId) { + throw new Error("cli-bridge stream ended without a terminal result"); + } + if (lastEventId) { + const result = await readFullCliBridgeResult( + options, + requestBody, + transport, + signal, + ); + yield { + type: "result", + data: { + finalText: result.text, + finishReason: result.finishReason, + status: "completed", + }, + id: terminalCursor ?? lastEventId, + }; + } +} + +class CliBridgeRequestRejectedError extends Error { + constructor(readonly status: number, detail: string) { + super(`cli-bridge ${status}: ${detail}`); + this.name = "CliBridgeRequestRejectedError"; } - if (!completed) throw new Error("cli-bridge stream ended without a terminal result"); +} + +async function readFullCliBridgeResult( + options: CliBridgeProviderOptions, + requestBody: string, + transport: CliBridgeTransport, + signal?: AbortSignal, +): Promise<{ text: string; finishReason: string }> { + const body = safeJson(requestBody); + if (!body) throw new Error("cli-bridge replay request is not valid JSON"); + const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, { + method: "POST", + headers: requestHeaders(options), + body: JSON.stringify({ ...body, stream: false }), + signal, + }); + if (!response.ok) { + throw new Error(`cli-bridge replay result ${response.status}: ${await response.text()}`); + } + const parsed = safeJson(await response.text()); + if (parsed?.error && typeof parsed.error === "object") { + const error = parsed.error as Record; + const message = + typeof error.message === "string" ? error.message : "cli-bridge replay failed"; + throw new Error(`cli-bridge replay result failed: ${message}`); + } + const choice = Array.isArray(parsed?.choices) ? parsed.choices[0] : undefined; + const message = + choice?.message && typeof choice.message === "object" + ? choice.message as Record + : undefined; + if ( + typeof message?.content !== "string" || + typeof choice?.finish_reason !== "string" + ) { + throw new Error("cli-bridge replay result returned an invalid completion"); + } + if (choice.finish_reason === "error" || choice.finish_reason === "timeout") { + throw new Error(`cli-bridge replay result ended ${choice.finish_reason}`); + } + return { text: message.content, finishReason: choice.finish_reason }; } interface CliBridgeTransport { @@ -188,9 +496,9 @@ interface CliBridgeTransport { } interface CliBridgeRequest { - method: "POST"; + method: "GET" | "POST"; headers: Record; - body: string; + body?: string; signal?: AbortSignal; } @@ -235,13 +543,15 @@ function toChatCompletionsBody( options: CliBridgeProviderOptions, environmentInput: CreateAgentEnvironmentInput, turn: AgentTurnInput, + runId: string, ): Record { const profile = inlineProfile(environmentInput.profile); return { - model: turn.model ?? environmentInput.backend ?? options.defaultModel ?? "opencode", + model: resolveBridgeModel(options, environmentInput, turn, profile), messages: messagesFromTurn(turn, profile), stream: true, ...(turn.sessionId ? { session_id: turn.sessionId } : {}), + run_id: runId, ...(options.defaultMode ? { mode: options.defaultMode } : {}), ...(profile ? { agent_profile: profile } : {}), ...(environmentInput.env ? { env: environmentInput.env } : {}), @@ -255,6 +565,28 @@ function toChatCompletionsBody( }; } +function resolveBridgeModel( + options: CliBridgeProviderOptions, + environmentInput: CreateAgentEnvironmentInput, + turn: AgentTurnInput, + profile: AgentProfile | undefined, +): string { + const harness = environmentInput.backend ?? profile?.harness; + const model = turn.model ?? options.defaultModel ?? profile?.model?.default; + const provider = profile?.model?.provider; + if (!harness) { + if (model) return model; + throw new Error( + "createCliBridgeProvider requires an explicit bridge model or a profile/backend harness", + ); + } + if (!model || model === harness) return harness; + if (model.startsWith(`${harness}/`)) return model; + if (model.includes("/")) return `${harness}/${model}`; + if (provider) return `${harness}/${provider}/${model}`; + return `${harness}/${model}`; +} + function messagesFromTurn(turn: AgentTurnInput, profile: AgentProfile | undefined): Array> { const messages: Array> = []; const systemPrompt = profile?.prompt?.systemPrompt; @@ -285,7 +617,12 @@ function executionFromInput( }; } -async function* parseSse(body: AsyncIterable): AsyncIterable { +interface CliBridgeSseFrame { + readonly data: string; + readonly id?: string; +} + +async function* parseSse(body: AsyncIterable): AsyncIterable { const decoder = new TextDecoder(); let buffer = ""; for await (const value of body) { @@ -295,13 +632,13 @@ async function* parseSse(body: AsyncIterable): AsyncIterable const frame = buffer.slice(0, boundary.index); buffer = buffer.slice(boundary.index + boundary.length); const data = dataFromFrame(frame); - if (data !== undefined) yield data; + if (data) yield data; boundary = findFrameBoundary(buffer); } } if (buffer) { const data = dataFromFrame(buffer); - if (data !== undefined) yield data; + if (data) yield data; } } @@ -313,13 +650,25 @@ function findFrameBoundary(value: string): { index: number; length: number } | u return { index: lf, length: 2 }; } -function dataFromFrame(frame: string): string | undefined { +function dataFromFrame(frame: string): CliBridgeSseFrame | undefined { const lines = frame.split(/\r?\n/); const data = lines .filter((line) => line.startsWith("data:")) .map((line) => line.slice("data:".length).trimStart()) .join("\n"); - return data || undefined; + if (!data) return undefined; + const id = lines.find((line) => line.startsWith("id:"))?.slice("id:".length).trim(); + return { data, ...(id ? { id } : {}) }; +} + +function* eventsWithCursor( + events: readonly AgentEnvironmentEvent[], + cursor?: string, +): Iterable { + for (let index = 0; index < events.length; index += 1) { + const event = events[index]!; + yield cursor && index === events.length - 1 ? { ...event, id: cursor } : event; + } } function toolCallsFromDelta(value: unknown): Array<{ id?: string; index: number; name?: string }> { @@ -371,6 +720,63 @@ function usageFromOpenAi(value: unknown): TokenUsage | undefined { }; } +function requestHeaders(options: CliBridgeProviderOptions): Record { + return { + "content-type": "application/json", + ...(options.bearerToken ? { authorization: `Bearer ${options.bearerToken}` } : {}), + }; +} + +function cliBridgeRunId( + environmentId: string, + turn: AgentTurnInput, + turnId: string, +): string { + if ( + turn.executionId && + turn.executionId.length <= 128 && + /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(turn.executionId) + ) { + return turn.executionId; + } + const digest = createHash("sha256") + .update(environmentId) + .update("\0") + .update(turn.sessionId ?? "") + .update("\0") + .update(turn.executionId ?? turnId) + .digest("hex"); + return `agent-${digest}`; +} + +function runSnapshot(value: string): CliBridgeRunSnapshot { + const parsed = safeJson(value); + if (!parsed) throw new Error("cli-bridge run status returned invalid JSON"); + const id = parsed.id; + const status = parsed.status; + const terminal = parsed.terminal; + if ( + typeof id !== "string" || + !["running", "done", "error", "cancelled"].includes(String(status)) || + typeof terminal !== "boolean" + ) { + throw new Error("cli-bridge run status returned an invalid snapshot"); + } + return { + id, + status: status as CliBridgeRunSnapshot["status"], + terminal, + }; +} + +function cancelSnapshot(value: string): CliBridgeRunSnapshot { + const parsed = safeJson(value); + if (!parsed || !parsed.run || typeof parsed.run !== "object") { + throw new Error("cli-bridge cancel returned an invalid snapshot"); + } + return runSnapshot(JSON.stringify(parsed.run)); +} + function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -402,7 +808,7 @@ export function defaultCliBridgeCapabilities(): AgentEnvironmentCapabilities { runtimeUpdate: false, validation: false, }, - streaming: { live: true, replay: false, detach: false, turnIdempotency: true }, + streaming: { live: true, replay: true, detach: false, turnIdempotency: true }, sessions: { continue: true, list: false, messages: false }, workspace: { read: false, write: false, exec: false, git: false, upload: false, download: false }, branching: { checkpoint: false, fork: false },