@tangle-network/agent-provider-cli-bridge 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,9 @@ Passing the same `sessionId` on later turns continues the same CLI conversation.
49
49
  `dispatch()` starts a bridge-owned durable run and returns after detaching its HTTP reader.
50
50
  The returned `AgentSession` exposes status, cursor-based event replay, the terminal result, continuation, and cancellation.
51
51
  Cancellation returns only after cli-bridge confirms the run is terminal.
52
+ A direct stream treats a finish frame as provisional until cli-bridge returns the same run's aggregate result.
53
+ A retained replay confirms its terminal state from the retained run endpoint.
54
+ The provider rejects a terminal response when its run id or request digest does not match the accepted stream.
52
55
  Usage events and terminal result metadata include `modelRequests` only when the bridge measured an exact non-negative integer count.
53
56
  Missing, malformed, partial, or estimated request counts remain unknown.
54
57
  When `dispatch()` receives no `sessionId`, it creates one from that turn's stable run id and returns it.
@@ -27,7 +27,7 @@ export async function* streamTrackedCliBridgeTurn(options, environmentInput, pre
27
27
  let drained = false;
28
28
  let threw = false;
29
29
  try {
30
- for await (const event of streamCliBridgeTurn(options, turn, run.requestBody, transport, run.id, originalTurn.lastEventId, signal, (response) => captureCliBridgeRunIdentity(response, run, false))) {
30
+ for await (const event of streamCliBridgeTurn(options, turn, run.requestBody, transport, run.id, originalTurn.lastEventId, signal, (response) => captureCliBridgeRunIdentity(response, run, true), () => getCliBridgeRun(options, transport, run, 30_000, signal))) {
31
31
  yield event;
32
32
  }
33
33
  drained = true;
@@ -171,7 +171,7 @@ export async function* streamCliBridgeSessionEvents(options, environmentInput, r
171
171
  ...(run.sessionId ? { sessionId: run.sessionId } : {}),
172
172
  executionId: run.executionId,
173
173
  turnId: run.turnId,
174
- }, undefined, transport, run.id, eventOptions?.since ?? "0", signal, (response) => captureCliBridgeRunIdentity(response, run, false));
174
+ }, undefined, transport, run.id, eventOptions?.since ?? "0", signal, (response) => captureCliBridgeRunIdentity(response, run, false), () => getCliBridgeRun(options, transport, run, 30_000, signal));
175
175
  drained = true;
176
176
  if (runs.get(run.id) === run)
177
177
  runs.delete(run.id);
@@ -1,8 +1,9 @@
1
1
  import type { AgentEnvironmentEvent, AgentTurnInput } from "@tangle-network/agent-interface/environment-provider";
2
2
  import type { CliBridgeProviderOptions } from "./provider-options.js";
3
+ import type { CliBridgeRunSnapshot } from "./retained-run-state.js";
3
4
  import type { CliBridgeResponse, CliBridgeTransport } from "./transport.js";
4
5
  export declare class CliBridgeRequestRejectedError extends Error {
5
6
  readonly status: number;
6
7
  constructor(status: number, detail: string);
7
8
  }
8
- export declare function streamCliBridgeTurn(options: CliBridgeProviderOptions, turn: AgentTurnInput, requestBody: string | undefined, transport: CliBridgeTransport, runId: string, lastEventId?: string, signal?: AbortSignal, onAccepted?: (response: CliBridgeResponse) => void): AsyncIterable<AgentEnvironmentEvent>;
9
+ export declare function streamCliBridgeTurn(options: CliBridgeProviderOptions, turn: AgentTurnInput, requestBody: string | undefined, transport: CliBridgeTransport, runId: string, lastEventId?: string, signal?: AbortSignal, onResponse?: (response: CliBridgeResponse) => void, readAuthoritativeRun?: () => Promise<CliBridgeRunSnapshot | null>): AsyncIterable<AgentEnvironmentEvent>;
@@ -1,4 +1,5 @@
1
1
  import { retainedEventsWithIdentity, retainedReplayRequest, } from "./retained-event-cursor.js";
2
+ import { detachCliBridgeReader } from "./retained-control.js";
2
3
  import { requestHeaders, trimSlash } from "./transport.js";
3
4
  import { modelRequestsFromOpenAi, parseSse, responseIdentityFromOpenAi, safeJson, toolCallsFromDelta, usageFromOpenAi, } from "./wire.js";
4
5
  export class CliBridgeRequestRejectedError extends Error {
@@ -17,7 +18,7 @@ class CliBridgeRunCancelledError extends Error {
17
18
  this.name = "CliBridgeRunCancelledError";
18
19
  }
19
20
  }
20
- export async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onAccepted) {
21
+ export async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun) {
21
22
  const reconnect = requestBody === undefined;
22
23
  const replay = retainedReplayRequest(lastEventId);
23
24
  const response = await transport.fetch(reconnect
@@ -45,9 +46,20 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
45
46
  }
46
47
  throw new CliBridgeRequestRejectedError(response.status, detail);
47
48
  }
48
- onAccepted?.(response);
49
49
  if (!response.body)
50
50
  throw new Error("cli-bridge response body is empty");
51
+ try {
52
+ onResponse?.(response);
53
+ }
54
+ catch (error) {
55
+ try {
56
+ await detachCliBridgeReader(response.body);
57
+ }
58
+ catch (detachError) {
59
+ throw new AggregateError([error, detachError], `cli-bridge rejected response identity for run "${runId}" and could not detach its reader`);
60
+ }
61
+ throw error;
62
+ }
51
63
  let text = "";
52
64
  const sessionId = turn.sessionId ?? runId;
53
65
  const messageId = turn.turnId ?? `${sessionId}:assistant`;
@@ -59,10 +71,10 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
59
71
  let servedModel;
60
72
  let systemFingerprint;
61
73
  let textBoundaryPending = false;
62
- let terminalCursor;
63
74
  let sawProtocolEnd = false;
64
75
  let terminalFrameSeen = false;
65
76
  let cancelledTerminalSeen = false;
77
+ let terminalFrame;
66
78
  for await (const frame of parseSse(response.body)) {
67
79
  if (frame.data === "[DONE]") {
68
80
  sawProtocolEnd = true;
@@ -198,95 +210,160 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
198
210
  throw new Error("cli-bridge returned finish_reason=error");
199
211
  }
200
212
  completed = true;
201
- if (lastEventId && requestBody !== undefined) {
202
- terminalCursor = frame.id === undefined
203
- ? undefined
204
- : `${frame.id}:${frameEvents.length}`;
205
- }
206
- else {
207
- frameEvents.push({
208
- type: "result",
209
- data: {
210
- finalText: text,
211
- finishReason: choice.finish_reason,
212
- status: "completed",
213
- ...identityData,
214
- ...(modelRequestsKnown ? { modelRequests: observedModelRequests } : {}),
215
- },
216
- });
217
- }
213
+ terminalFrame = {
214
+ ...(frame.id === undefined ? {} : { id: frame.id }),
215
+ events: frameEvents,
216
+ finishReason: choice.finish_reason,
217
+ };
218
+ continue;
218
219
  }
219
220
  yield* retainedEventsWithIdentity(frameEvents, frame.id, runId, sessionId, turn.executionId, replay.anchor);
220
221
  }
221
222
  if (!sawProtocolEnd) {
222
223
  throw new Error("cli-bridge stream ended without the [DONE] protocol marker");
223
224
  }
225
+ if (cancelledTerminalSeen)
226
+ return;
224
227
  if (!completed && lastEventId === undefined) {
228
+ let snapshot;
229
+ try {
230
+ snapshot = await terminalRunSnapshot(runId, readAuthoritativeRun);
231
+ }
232
+ catch (error) {
233
+ throw new Error("cli-bridge stream ended without a terminal result", {
234
+ cause: error,
235
+ });
236
+ }
237
+ if (snapshot.status === "cancelled") {
238
+ const event = cancelledEvent(`cli-bridge run "${runId}" was cancelled`);
239
+ yield {
240
+ ...event,
241
+ data: {
242
+ ...event.data,
243
+ runId,
244
+ sessionId,
245
+ ...(turn.executionId === undefined
246
+ ? {}
247
+ : { executionId: turn.executionId }),
248
+ },
249
+ };
250
+ return;
251
+ }
225
252
  throw new Error("cli-bridge stream ended without a terminal result");
226
253
  }
227
- if (lastEventId && requestBody !== undefined && !cancelledTerminalSeen) {
254
+ if (!terminalFrame) {
255
+ if (requestBody !== undefined && lastEventId !== undefined) {
256
+ try {
257
+ await readFullCliBridgeResult(options, requestBody, transport, signal, onResponse);
258
+ }
259
+ catch (error) {
260
+ if (error instanceof CliBridgeRunCancelledError)
261
+ return;
262
+ throw error;
263
+ }
264
+ }
265
+ else if (requestBody === undefined) {
266
+ await terminalRunSnapshot(runId, readAuthoritativeRun);
267
+ }
268
+ return;
269
+ }
270
+ let terminalEvent;
271
+ let terminalFailure;
272
+ if (requestBody !== undefined) {
228
273
  let result;
229
274
  try {
230
- result = await readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted);
275
+ result = await readFullCliBridgeResult(options, requestBody, transport, signal, onResponse);
231
276
  }
232
277
  catch (error) {
233
278
  if (error instanceof CliBridgeRunCancelledError) {
234
- // A normal finish frame can race with the bridge's authoritative
235
- // cancelled state. Replace the withheld result at the same cursor.
236
- if (terminalCursor !== undefined) {
237
- yield {
238
- type: "status",
239
- data: {
240
- status: "cancelled",
241
- error: error.detail,
242
- runId,
243
- sessionId,
244
- ...(turn.executionId === undefined
245
- ? {}
246
- : { executionId: turn.executionId }),
247
- cursor: terminalCursor,
248
- },
249
- normalized: {
250
- type: "status",
251
- status: "cancelled",
252
- detail: error.detail,
253
- },
254
- id: terminalCursor,
255
- };
256
- }
257
- // If no new terminal frame followed the requested cursor, the caller
258
- // already consumed the cancellation event. Do not duplicate it.
259
- return;
279
+ terminalEvent = cancelledEvent(error.detail);
280
+ }
281
+ else {
282
+ throw error;
260
283
  }
261
- throw error;
262
284
  }
263
- const finalModelRequests = result.modelRequests ??
264
- (modelRequestsKnown ? observedModelRequests : undefined);
265
- const finalCursor = terminalCursor ?? lastEventId;
266
- yield {
267
- type: "result",
268
- data: {
269
- finalText: result.text,
270
- finishReason: result.finishReason,
271
- status: "completed",
272
- ...(result.model === undefined ? {} : { model: result.model }),
273
- ...(result.system_fingerprint === undefined
274
- ? {}
275
- : { system_fingerprint: result.system_fingerprint }),
276
- runId,
277
- sessionId,
278
- ...(turn.executionId === undefined ? {} : { executionId: turn.executionId }),
279
- ...(finalCursor === undefined ? {} : { cursor: finalCursor }),
280
- ...(finalModelRequests === undefined
281
- ? {}
282
- : { modelRequests: finalModelRequests }),
283
- },
284
- ...(result.usage && !sawUsage ? { usage: result.usage } : {}),
285
- ...(finalCursor === undefined ? {} : { id: finalCursor }),
286
- };
285
+ if (result !== undefined) {
286
+ const finalModelRequests = result.modelRequests ??
287
+ (modelRequestsKnown ? observedModelRequests : undefined);
288
+ terminalEvent = {
289
+ type: "result",
290
+ data: {
291
+ finalText: lastEventId === undefined ? text : result.text,
292
+ finishReason: lastEventId === undefined ? terminalFrame.finishReason : result.finishReason,
293
+ status: "completed",
294
+ ...(result.model === undefined ? {} : { model: result.model }),
295
+ ...(result.system_fingerprint === undefined
296
+ ? {}
297
+ : { system_fingerprint: result.system_fingerprint }),
298
+ ...(finalModelRequests === undefined
299
+ ? {}
300
+ : { modelRequests: finalModelRequests }),
301
+ },
302
+ ...(result.usage && !sawUsage ? { usage: result.usage } : {}),
303
+ };
304
+ }
305
+ }
306
+ else {
307
+ const snapshot = await terminalRunSnapshot(runId, readAuthoritativeRun);
308
+ if (snapshot.status === "cancelled") {
309
+ terminalEvent = cancelledEvent(`cli-bridge run "${runId}" was cancelled`);
310
+ }
311
+ else if (snapshot.status === "error") {
312
+ const detail = `cli-bridge run "${runId}" ended with an error`;
313
+ terminalEvent = failedEvent(detail);
314
+ terminalFailure = new Error(detail);
315
+ }
316
+ else {
317
+ terminalEvent = {
318
+ type: "result",
319
+ data: {
320
+ finalText: text,
321
+ finishReason: terminalFrame.finishReason,
322
+ status: "completed",
323
+ ...(servedModel === undefined ? {} : { model: servedModel }),
324
+ ...(systemFingerprint === undefined
325
+ ? {}
326
+ : { system_fingerprint: systemFingerprint }),
327
+ ...(modelRequestsKnown ? { modelRequests: observedModelRequests } : {}),
328
+ },
329
+ };
330
+ }
331
+ }
332
+ if (!terminalEvent) {
333
+ throw new Error("cli-bridge terminal state did not produce a terminal event");
334
+ }
335
+ terminalFrame.events.push(terminalEvent);
336
+ yield* retainedEventsWithIdentity(terminalFrame.events, terminalFrame.id, runId, sessionId, turn.executionId, replay.anchor);
337
+ if (terminalFailure)
338
+ throw terminalFailure;
339
+ }
340
+ async function terminalRunSnapshot(runId, readAuthoritativeRun) {
341
+ if (!readAuthoritativeRun) {
342
+ throw new Error("cli-bridge retained stream omitted authoritative run status");
343
+ }
344
+ const snapshot = await readAuthoritativeRun();
345
+ if (!snapshot)
346
+ throw new Error(`cli-bridge lost run "${runId}" before terminal replay`);
347
+ if (!snapshot.terminal || snapshot.status === "running") {
348
+ throw new Error(`cli-bridge run "${runId}" remained active after its stream ended`);
287
349
  }
350
+ return snapshot;
351
+ }
352
+ function cancelledEvent(detail) {
353
+ return {
354
+ type: "status",
355
+ data: { status: "cancelled", error: detail },
356
+ normalized: { type: "status", status: "cancelled", detail },
357
+ };
358
+ }
359
+ function failedEvent(detail) {
360
+ return {
361
+ type: "status",
362
+ data: { status: "failed", error: detail },
363
+ normalized: { type: "status", status: "failed", detail },
364
+ };
288
365
  }
289
- async function readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted) {
366
+ async function readFullCliBridgeResult(options, requestBody, transport, signal, onResponse) {
290
367
  const body = safeJson(requestBody);
291
368
  if (!body)
292
369
  throw new Error("cli-bridge replay request is not valid JSON");
@@ -296,16 +373,16 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal,
296
373
  body: JSON.stringify({ ...body, stream: false }),
297
374
  ...(signal ? { signal } : {}),
298
375
  });
376
+ const responseText = await response.text();
377
+ onResponse?.(response);
299
378
  if (!response.ok) {
300
- const responseText = await response.text();
301
379
  const error = cliBridgeError(safeJson(responseText));
302
380
  if (error?.type === "run_cancelled") {
303
381
  throw new CliBridgeRunCancelledError(cliBridgeErrorMessage(error));
304
382
  }
305
383
  throw new Error(`cli-bridge replay result ${response.status}: ${responseText}`);
306
384
  }
307
- onAccepted?.(response);
308
- const parsed = safeJson(await response.text());
385
+ const parsed = safeJson(responseText);
309
386
  const error = cliBridgeError(parsed);
310
387
  if (error) {
311
388
  if (error.type === "run_cancelled") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-cli-bridge",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "AgentEnvironmentProvider adapter for cli-bridge /v1/chat/completions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  "LICENSE"
50
50
  ],
51
51
  "dependencies": {
52
- "@tangle-network/agent-interface": "0.52.0",
52
+ "@tangle-network/agent-interface": "0.53.0",
53
53
  "undici": "7.29.0"
54
54
  },
55
55
  "devDependencies": {