@tangle-network/agent-provider-cli-bridge 0.4.2 → 0.5.0

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.
Files changed (3) hide show
  1. package/README.md +23 -1
  2. package/dist/index.js +414 -22
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -17,6 +17,22 @@ const environment = await provider.create({
17
17
  model: { default: 'gpt-5' },
18
18
  },
19
19
  })
20
+
21
+ const reference = await environment.dispatch({
22
+ prompt: 'inspect the repository',
23
+ sessionId: 'research-session',
24
+ executionId: 'research-turn-1',
25
+ })
26
+ const session = environment.session(reference.id)
27
+
28
+ for await (const event of session.events({ since: '0' })) {
29
+ // Sequence-numbered bridge events, including normalized usage and result data.
30
+ }
31
+
32
+ await session.prompt({
33
+ prompt: 'change direction using the evidence already collected',
34
+ executionId: 'research-turn-2',
35
+ })
20
36
  ```
21
37
 
22
38
  The bridge model is selected from run data in this order: the turn, the provider default, or the profile's `harness` plus `model.default`.
@@ -24,7 +40,13 @@ Execution fails before network use when none is present.
24
40
 
25
41
  Passing the same `sessionId` on later turns continues the same CLI conversation.
26
42
  `executionId` gives a turn stable bridge identity, and `lastEventId` reattaches after a reader failure.
27
- Stopping a reader or destroying the environment cancels every active bridge run and waits for terminal confirmation.
43
+ `dispatch()` starts a bridge-owned durable run and returns after detaching its HTTP reader.
44
+ The returned `AgentSession` exposes status, cursor-based event replay, the terminal result, continuation, and cancellation.
45
+ Cancellation returns only after cli-bridge confirms the run is terminal.
46
+ When `dispatch()` receives no `sessionId`, it creates one from that turn's stable run id and returns it.
47
+ Replay and result reads fail loudly after cli-bridge's configured replay retention expires.
48
+ Stopping a `session.events()` reader detaches only that replay observer.
49
+ Stopping a direct `environment.stream()` reader or destroying the environment cancels its active bridge runs and waits for terminal confirmation.
28
50
 
29
51
  Response headers and streamed bodies have no transport timeout by default.
30
52
  For unattended runs, set `headersTimeoutMs`, `bodyTimeoutMs`, or an `AbortSignal` so an unresponsive bridge cannot wait forever.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { harnessSystemPromptIntents, } from "@tangle-network/agent-interface";
2
+ import { harnessSystemPromptIntents, snapshotAgentProfile, } from "@tangle-network/agent-interface";
3
3
  import { Agent, fetch as undiciFetch } from "undici";
4
4
  export function createCliBridgeProvider(options) {
5
5
  assertTimeout(options.headersTimeoutMs, "headersTimeoutMs");
@@ -10,16 +10,25 @@ export function createCliBridgeProvider(options) {
10
10
  name,
11
11
  capabilities: () => options.capabilities ?? defaultCliBridgeCapabilities(),
12
12
  async create(input) {
13
+ if (typeof input.profile === "string") {
14
+ throw new Error(`createCliBridgeProvider requires an inline AgentProfile; named profile "${input.profile}" is unsupported`);
15
+ }
16
+ const environmentInput = {
17
+ ...input,
18
+ profile: snapshotAgentProfile(input.profile),
19
+ };
13
20
  const transport = createTransport(options);
14
21
  const environmentId = input.idempotencyKey ?? crypto.randomUUID();
15
22
  const runs = new Map();
23
+ const sessions = new Map();
16
24
  const readers = new Set();
17
25
  let destroyed = false;
18
26
  let closePromise;
19
27
  const stream = async function* (turn) {
20
28
  if (destroyed)
21
29
  throw new Error("cli-bridge environment is destroyed");
22
- yield* streamTrackedCliBridgeTurn(options, input, turn, transport, environmentId, runs, readers);
30
+ const prepared = prepareCliBridgeRun(options, environmentInput, turn, environmentId, false);
31
+ yield* streamTrackedCliBridgeTurn(options, environmentInput, prepared, transport, runs, sessions, readers);
23
32
  };
24
33
  const environment = {
25
34
  id: environmentId,
@@ -27,6 +36,26 @@ export function createCliBridgeProvider(options) {
27
36
  ...(input.name ? { name: input.name } : {}),
28
37
  status: async () => (destroyed ? "stopped" : "running"),
29
38
  stream,
39
+ async dispatch(turn) {
40
+ if (destroyed)
41
+ throw new Error("cli-bridge environment is destroyed");
42
+ const prepared = prepareCliBridgeRun(options, environmentInput, turn, environmentId, true);
43
+ return dispatchCliBridgeTurn(options, environmentInput, prepared, transport, name, runs, sessions);
44
+ },
45
+ session(id) {
46
+ return createCliBridgeSession({
47
+ id,
48
+ providerName: name,
49
+ options,
50
+ environmentInput,
51
+ environmentId,
52
+ transport,
53
+ runs,
54
+ sessions,
55
+ readers,
56
+ isDestroyed: () => destroyed,
57
+ });
58
+ },
30
59
  placement: async () => ({
31
60
  kind: options.defaultExecution?.kind === "sandbox" ? "sandbox" : "local",
32
61
  providerMetadata: { baseUrl: options.baseUrl },
@@ -54,6 +83,7 @@ export function createCliBridgeProvider(options) {
54
83
  reader.abort(new DOMException("cli-bridge environment was destroyed", "AbortError"));
55
84
  }
56
85
  await transport.close();
86
+ sessions.clear();
57
87
  })();
58
88
  closePromise = attempt;
59
89
  try {
@@ -71,13 +101,63 @@ export function createCliBridgeProvider(options) {
71
101
  },
72
102
  };
73
103
  }
74
- async function* streamTrackedCliBridgeTurn(options, environmentInput, originalTurn, transport, environmentId, runs, readers) {
104
+ function prepareCliBridgeRun(options, environmentInput, originalTurn, environmentId, requireSession) {
105
+ const turnId = originalTurn.turnId ?? crypto.randomUUID();
106
+ const runId = cliBridgeRunId(environmentId, originalTurn, turnId);
107
+ const sessionId = originalTurn.sessionId ?? (requireSession ? runId : undefined);
108
+ const turn = {
109
+ ...originalTurn,
110
+ turnId,
111
+ ...(sessionId ? { sessionId } : {}),
112
+ };
113
+ return {
114
+ turn,
115
+ run: {
116
+ id: runId,
117
+ ...(sessionId ? { sessionId } : {}),
118
+ turnId,
119
+ requestBody: JSON.stringify(toChatCompletionsBody(options, environmentInput, turn, runId)),
120
+ readers: new Set(),
121
+ },
122
+ };
123
+ }
124
+ function bindCliBridgeRun(prepared, runs) {
125
+ const previous = runs.get(prepared.id);
126
+ runs.set(prepared.id, prepared);
127
+ return previous;
128
+ }
129
+ function restoreCliBridgeRun(run, previous, runs) {
130
+ if (runs.get(run.id) !== run)
131
+ return;
132
+ if (previous) {
133
+ runs.set(run.id, previous);
134
+ }
135
+ else {
136
+ runs.delete(run.id);
137
+ }
138
+ }
139
+ function bindCliBridgeSession(run, sessions) {
140
+ if (!run.sessionId)
141
+ return undefined;
142
+ const previous = sessions.get(run.sessionId)?.current;
143
+ sessions.set(run.sessionId, { id: run.sessionId, current: run });
144
+ return previous;
145
+ }
146
+ function restoreCliBridgeSession(run, previous, sessions) {
147
+ if (!run.sessionId || sessions.get(run.sessionId)?.current !== run)
148
+ return;
149
+ if (previous) {
150
+ sessions.set(run.sessionId, { id: run.sessionId, current: previous });
151
+ }
152
+ else {
153
+ sessions.delete(run.sessionId);
154
+ }
155
+ }
156
+ async function* streamTrackedCliBridgeTurn(options, environmentInput, prepared, transport, runs, sessions, readers) {
157
+ const originalTurn = prepared.turn;
75
158
  if (originalTurn.detach) {
76
159
  throw new Error("cli-bridge provider does not support detached turns");
77
160
  }
78
- const sessionId = originalTurn.sessionId;
79
- const turnId = originalTurn.turnId ?? crypto.randomUUID();
80
- const runId = cliBridgeRunId(environmentId, originalTurn, turnId);
81
161
  const controller = new AbortController();
82
162
  const signals = [
83
163
  originalTurn.signal,
@@ -86,21 +166,17 @@ async function* streamTrackedCliBridgeTurn(options, environmentInput, originalTu
86
166
  ].filter((signal) => signal !== undefined);
87
167
  const turn = {
88
168
  ...originalTurn,
89
- turnId,
90
169
  ...(signals.length > 0 ? { signal: AbortSignal.any(signals) } : {}),
91
170
  };
92
- const requestBody = JSON.stringify(toChatCompletionsBody(options, environmentInput, turn, runId));
93
- const run = runs.get(runId) ?? {
94
- id: runId,
95
- readers: new Set(),
96
- };
171
+ const run = prepared.run;
172
+ const previousRun = bindCliBridgeRun(run, runs);
173
+ const previousSessionRun = bindCliBridgeSession(run, sessions);
97
174
  run.readers.add(controller);
98
175
  readers.add(controller);
99
- runs.set(runId, run);
100
176
  let drained = false;
101
177
  let threw = false;
102
178
  try {
103
- for await (const event of streamCliBridgeTurn(options, turn, requestBody, transport, runId, originalTurn.lastEventId, signals.length > 0 ? AbortSignal.any(signals) : undefined)) {
179
+ for await (const event of streamCliBridgeTurn(options, turn, run.requestBody, transport, run.id, originalTurn.lastEventId, signals.length > 0 ? AbortSignal.any(signals) : undefined, (response) => captureCliBridgeRunIdentity(response, run, false))) {
104
180
  yield event;
105
181
  }
106
182
  drained = true;
@@ -110,8 +186,8 @@ async function* streamTrackedCliBridgeTurn(options, environmentInput, originalTu
110
186
  catch (error) {
111
187
  threw = true;
112
188
  if (error instanceof CliBridgeRequestRejectedError) {
113
- if (runs.get(run.id) === run)
114
- runs.delete(run.id);
189
+ restoreCliBridgeRun(run, previousRun, runs);
190
+ restoreCliBridgeSession(run, previousSessionRun, sessions);
115
191
  throw error;
116
192
  }
117
193
  let snapshot;
@@ -141,6 +217,299 @@ async function* streamTrackedCliBridgeTurn(options, environmentInput, originalTu
141
217
  readers.delete(controller);
142
218
  }
143
219
  }
220
+ async function dispatchCliBridgeTurn(options, environmentInput, prepared, transport, providerName, runs, sessions) {
221
+ const run = prepared.run;
222
+ const previousRun = bindCliBridgeRun(run, runs);
223
+ const previousSessionRun = bindCliBridgeSession(run, sessions);
224
+ const signals = [
225
+ prepared.turn.signal,
226
+ environmentInput.signal,
227
+ ].filter((signal) => signal !== undefined);
228
+ const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
229
+ let accepted = false;
230
+ let responseBody;
231
+ try {
232
+ const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, {
233
+ method: "POST",
234
+ headers: {
235
+ ...requestHeaders(options),
236
+ accept: "text/event-stream",
237
+ ...(run.sessionId ? { "x-session-id": run.sessionId } : {}),
238
+ },
239
+ body: run.requestBody,
240
+ ...(signal ? { signal } : {}),
241
+ });
242
+ if (!response.ok) {
243
+ let detail = "request rejected";
244
+ try {
245
+ detail = await response.text();
246
+ }
247
+ catch {
248
+ // The HTTP status already proves this request was rejected.
249
+ }
250
+ throw new CliBridgeRequestRejectedError(response.status, detail);
251
+ }
252
+ accepted = true;
253
+ if (!response.body)
254
+ throw new Error("cli-bridge response body is empty");
255
+ responseBody = response.body;
256
+ captureCliBridgeRunIdentity(response, run, true);
257
+ await detachCliBridgeReader(responseBody);
258
+ responseBody = undefined;
259
+ return {
260
+ id: run.sessionId,
261
+ provider: providerName,
262
+ metadata: {
263
+ runId: run.id,
264
+ requestDigest: run.requestDigest,
265
+ },
266
+ };
267
+ }
268
+ catch (error) {
269
+ let failure = error;
270
+ if (responseBody) {
271
+ try {
272
+ await detachCliBridgeReader(responseBody);
273
+ }
274
+ catch (detachError) {
275
+ failure = new AggregateError([failure, detachError], `cli-bridge dispatch "${run.id}" failed and its reader did not detach`);
276
+ }
277
+ }
278
+ if (failure instanceof CliBridgeRequestRejectedError) {
279
+ restoreCliBridgeRun(run, previousRun, runs);
280
+ restoreCliBridgeSession(run, previousSessionRun, sessions);
281
+ throw failure;
282
+ }
283
+ if (accepted || signal?.aborted) {
284
+ try {
285
+ await cancelCliBridgeRun(options, transport, run);
286
+ if (runs.get(run.id) === run)
287
+ runs.delete(run.id);
288
+ }
289
+ catch (cancellationError) {
290
+ throw new AggregateError([failure, cancellationError], `cli-bridge dispatch "${run.id}" failed and cancellation was not confirmed`);
291
+ }
292
+ }
293
+ else {
294
+ restoreCliBridgeRun(run, previousRun, runs);
295
+ }
296
+ restoreCliBridgeSession(run, previousSessionRun, sessions);
297
+ throw failure;
298
+ }
299
+ }
300
+ function createCliBridgeSession(args) {
301
+ const currentRun = () => {
302
+ if (args.isDestroyed())
303
+ throw new Error("cli-bridge environment is destroyed");
304
+ return args.sessions.get(args.id)?.current;
305
+ };
306
+ const requireCurrentRun = () => {
307
+ const run = currentRun();
308
+ if (!run)
309
+ throw new Error(`cli-bridge session "${args.id}" has no run`);
310
+ return run;
311
+ };
312
+ return {
313
+ id: args.id,
314
+ async status() {
315
+ const run = currentRun();
316
+ if (!run)
317
+ return null;
318
+ const snapshot = await getCliBridgeRun(args.options, args.transport, run.id);
319
+ if (!snapshot)
320
+ return null;
321
+ if (snapshot.terminal && args.runs.get(run.id) === run) {
322
+ args.runs.delete(run.id);
323
+ }
324
+ return agentSessionStatusFromRun(snapshot);
325
+ },
326
+ async *events(options) {
327
+ const run = requireCurrentRun();
328
+ yield* streamCliBridgeSessionEvents(args.options, args.environmentInput, run, args.transport, args.runs, args.readers, options);
329
+ },
330
+ async result() {
331
+ const run = requireCurrentRun();
332
+ return collectCliBridgeTurnResult(streamCliBridgeSessionEvents(args.options, args.environmentInput, run, args.transport, args.runs, args.readers, { since: "0" }), run, args.options, args.transport);
333
+ },
334
+ async prompt(input) {
335
+ if (input.sessionId && input.sessionId !== args.id) {
336
+ throw new Error(`cli-bridge session "${args.id}" cannot prompt session "${input.sessionId}"`);
337
+ }
338
+ const prepared = prepareCliBridgeRun(args.options, args.environmentInput, {
339
+ ...input,
340
+ sessionId: args.id,
341
+ }, args.environmentId, false);
342
+ const result = await collectCliBridgeTurnResult(streamTrackedCliBridgeTurn(args.options, args.environmentInput, prepared, args.transport, args.runs, args.sessions, args.readers), prepared.run, args.options, args.transport);
343
+ return { ...result, sessionId: args.id };
344
+ },
345
+ async cancel() {
346
+ const run = requireCurrentRun();
347
+ await cancelCliBridgeRun(args.options, args.transport, run);
348
+ if (args.runs.get(run.id) === run)
349
+ args.runs.delete(run.id);
350
+ },
351
+ };
352
+ }
353
+ async function* streamCliBridgeSessionEvents(options, environmentInput, run, transport, runs, readers, eventOptions) {
354
+ const controller = new AbortController();
355
+ const signals = [
356
+ eventOptions?.signal,
357
+ environmentInput.signal,
358
+ controller.signal,
359
+ ].filter((signal) => signal !== undefined);
360
+ const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
361
+ run.readers.add(controller);
362
+ readers.add(controller);
363
+ let drained = false;
364
+ try {
365
+ yield* streamCliBridgeTurn(options, {
366
+ ...(run.sessionId ? { sessionId: run.sessionId } : {}),
367
+ turnId: run.turnId,
368
+ }, run.requestBody, transport, run.id, eventOptions?.since ?? "0", signal, (response) => captureCliBridgeRunIdentity(response, run, false));
369
+ drained = true;
370
+ if (runs.get(run.id) === run)
371
+ runs.delete(run.id);
372
+ }
373
+ finally {
374
+ if (!drained) {
375
+ controller.abort(new DOMException("cli-bridge session event reader detached", "AbortError"));
376
+ }
377
+ run.readers.delete(controller);
378
+ readers.delete(controller);
379
+ }
380
+ }
381
+ async function collectCliBridgeTurnResult(source, run, options, transport) {
382
+ const events = [];
383
+ let text = "";
384
+ let usage;
385
+ let streamError;
386
+ try {
387
+ for await (const event of source) {
388
+ events.push(event);
389
+ const finalText = event.data.finalText;
390
+ if (typeof finalText === "string") {
391
+ text = finalText;
392
+ }
393
+ else if (typeof event.data.delta === "string") {
394
+ text += event.data.delta;
395
+ }
396
+ usage = addTokenUsage(usage, event.usage);
397
+ }
398
+ }
399
+ catch (error) {
400
+ streamError = error;
401
+ }
402
+ const snapshot = await getCliBridgeRun(options, transport, run.id);
403
+ if (!snapshot) {
404
+ if (streamError)
405
+ throw streamError;
406
+ throw new Error(`cli-bridge lost run "${run.id}" before reading its result`);
407
+ }
408
+ if (!snapshot.terminal) {
409
+ if (streamError)
410
+ throw streamError;
411
+ throw new Error(`cli-bridge run "${run.id}" has no terminal result`);
412
+ }
413
+ const success = snapshot.status === "done" && streamError === undefined;
414
+ return {
415
+ text,
416
+ success,
417
+ ...(!success
418
+ ? {
419
+ error: streamError instanceof Error
420
+ ? streamError.message
421
+ : `cli-bridge run ended ${snapshot.status}`,
422
+ }
423
+ : {}),
424
+ ...(run.sessionId ? { sessionId: run.sessionId } : {}),
425
+ ...(usage ? { usage } : {}),
426
+ metadata: {
427
+ runId: run.id,
428
+ status: snapshot.status,
429
+ ...(run.requestDigest ? { requestDigest: run.requestDigest } : {}),
430
+ },
431
+ events,
432
+ };
433
+ }
434
+ function agentSessionStatusFromRun(snapshot) {
435
+ if (snapshot.status === "done")
436
+ return "completed";
437
+ if (snapshot.status === "error")
438
+ return "failed";
439
+ if (snapshot.status === "cancelled")
440
+ return "cancelled";
441
+ return "running";
442
+ }
443
+ function addTokenUsage(current, next) {
444
+ if (!next)
445
+ return current;
446
+ if (!current)
447
+ return { ...next };
448
+ return {
449
+ inputTokens: current.inputTokens + next.inputTokens,
450
+ outputTokens: current.outputTokens + next.outputTokens,
451
+ ...(current.totalTokens !== undefined || next.totalTokens !== undefined
452
+ ? { totalTokens: (current.totalTokens ?? 0) + (next.totalTokens ?? 0) }
453
+ : {}),
454
+ ...(current.cacheReadInputTokens !== undefined ||
455
+ next.cacheReadInputTokens !== undefined
456
+ ? {
457
+ cacheReadInputTokens: (current.cacheReadInputTokens ?? 0) +
458
+ (next.cacheReadInputTokens ?? 0),
459
+ }
460
+ : {}),
461
+ ...(current.cacheCreationInputTokens !== undefined ||
462
+ next.cacheCreationInputTokens !== undefined
463
+ ? {
464
+ cacheCreationInputTokens: (current.cacheCreationInputTokens ?? 0) +
465
+ (next.cacheCreationInputTokens ?? 0),
466
+ }
467
+ : {}),
468
+ ...(current.reasoningTokens !== undefined || next.reasoningTokens !== undefined
469
+ ? {
470
+ reasoningTokens: (current.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
471
+ }
472
+ : {}),
473
+ ...(current.cost !== undefined || next.cost !== undefined
474
+ ? { cost: (current.cost ?? 0) + (next.cost ?? 0) }
475
+ : {}),
476
+ };
477
+ }
478
+ function captureCliBridgeRunIdentity(response, run, required) {
479
+ const responseRunId = response.headers.get("x-run-id");
480
+ const requestDigest = response.headers.get("x-run-request-digest");
481
+ if (responseRunId !== null && responseRunId !== run.id) {
482
+ throw new Error(`cli-bridge accepted run "${responseRunId}" for requested run "${run.id}"`);
483
+ }
484
+ if ((required || run.requestDigest !== undefined) && responseRunId === null) {
485
+ throw new Error("cli-bridge response omitted X-Run-Id");
486
+ }
487
+ if (required && requestDigest === null) {
488
+ throw new Error("cli-bridge dispatch response omitted X-Run-Request-Digest");
489
+ }
490
+ if (requestDigest !== null &&
491
+ run.requestDigest !== undefined &&
492
+ requestDigest !== run.requestDigest) {
493
+ throw new Error(`cli-bridge changed request digest for run "${run.id}" from "${run.requestDigest}" to "${requestDigest}"`);
494
+ }
495
+ if (run.requestDigest !== undefined && requestDigest === null) {
496
+ throw new Error(`cli-bridge replay response omitted X-Run-Request-Digest for run "${run.id}"`);
497
+ }
498
+ if (requestDigest !== null)
499
+ run.requestDigest = requestDigest;
500
+ }
501
+ async function detachCliBridgeReader(body) {
502
+ const cancellable = body;
503
+ if (cancellable.cancel) {
504
+ await cancellable.cancel();
505
+ return;
506
+ }
507
+ const iterator = body[Symbol.asyncIterator]();
508
+ if (!iterator.return) {
509
+ throw new Error("cli-bridge response body cannot detach its reader");
510
+ }
511
+ await iterator.return();
512
+ }
144
513
  async function cancelCliBridgeRun(options, transport, run) {
145
514
  if (run.cancellation)
146
515
  return run.cancellation;
@@ -154,6 +523,7 @@ async function cancelCliBridgeRun(options, transport, run) {
154
523
  throw new Error(`cli-bridge cancel ${response.status}: ${await response.text()}`);
155
524
  }
156
525
  let snapshot = cancelSnapshot(await response.text());
526
+ assertCliBridgeRunSnapshotIdentity(snapshot, run.id);
157
527
  const waitBudgetMs = options.cancelWaitMs ?? 30_000;
158
528
  const deadline = Date.now() + waitBudgetMs;
159
529
  while (!snapshot.terminal) {
@@ -191,9 +561,16 @@ async function getCliBridgeRun(options, transport, runId, waitMs) {
191
561
  if (!response.ok) {
192
562
  throw new Error(`cli-bridge run status ${response.status}: ${await response.text()}`);
193
563
  }
194
- return runSnapshot(await response.text());
564
+ const snapshot = runSnapshot(await response.text());
565
+ assertCliBridgeRunSnapshotIdentity(snapshot, runId);
566
+ return snapshot;
567
+ }
568
+ function assertCliBridgeRunSnapshotIdentity(snapshot, expectedRunId) {
569
+ if (snapshot.id !== expectedRunId) {
570
+ throw new Error(`cli-bridge returned run "${snapshot.id}" for requested run "${expectedRunId}"`);
571
+ }
195
572
  }
196
- async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal) {
573
+ async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onAccepted) {
197
574
  const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, {
198
575
  method: "POST",
199
576
  headers: {
@@ -215,6 +592,7 @@ async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId
215
592
  }
216
593
  throw new CliBridgeRequestRejectedError(response.status, detail);
217
594
  }
595
+ onAccepted?.(response);
218
596
  if (!response.body)
219
597
  throw new Error("cli-bridge response body is empty");
220
598
  let text = "";
@@ -222,6 +600,7 @@ async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId
222
600
  const messageId = turn.turnId ?? `${sessionId}:assistant`;
223
601
  const emittedToolCalls = new Set();
224
602
  let completed = false;
603
+ let sawUsage = false;
225
604
  let terminalCursor;
226
605
  for await (const frame of parseSse(response.body)) {
227
606
  if (frame.data === "[DONE]")
@@ -245,6 +624,7 @@ async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId
245
624
  const nextUsage = usageFromOpenAi(parsed.usage);
246
625
  const frameEvents = [];
247
626
  if (nextUsage) {
627
+ sawUsage = true;
248
628
  frameEvents.push({ type: "usage", data: {}, usage: nextUsage });
249
629
  }
250
630
  if (chunk) {
@@ -321,7 +701,10 @@ async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId
321
701
  throw new Error("cli-bridge stream ended without a terminal result");
322
702
  }
323
703
  if (lastEventId) {
324
- const result = await readFullCliBridgeResult(options, requestBody, transport, signal);
704
+ const result = await readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted);
705
+ if (result.usage && !sawUsage) {
706
+ yield { type: "usage", data: {}, usage: result.usage };
707
+ }
325
708
  yield {
326
709
  type: "result",
327
710
  data: {
@@ -341,7 +724,7 @@ class CliBridgeRequestRejectedError extends Error {
341
724
  this.name = "CliBridgeRequestRejectedError";
342
725
  }
343
726
  }
344
- async function readFullCliBridgeResult(options, requestBody, transport, signal) {
727
+ async function readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted) {
345
728
  const body = safeJson(requestBody);
346
729
  if (!body)
347
730
  throw new Error("cli-bridge replay request is not valid JSON");
@@ -354,6 +737,7 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal)
354
737
  if (!response.ok) {
355
738
  throw new Error(`cli-bridge replay result ${response.status}: ${await response.text()}`);
356
739
  }
740
+ onAccepted?.(response);
357
741
  const parsed = safeJson(await response.text());
358
742
  if (parsed?.error && typeof parsed.error === "object") {
359
743
  const error = parsed.error;
@@ -371,7 +755,12 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal)
371
755
  if (choice.finish_reason === "error" || choice.finish_reason === "timeout") {
372
756
  throw new Error(`cli-bridge replay result ended ${choice.finish_reason}`);
373
757
  }
374
- return { text: message.content, finishReason: choice.finish_reason };
758
+ const usage = usageFromOpenAi(parsed?.usage);
759
+ return {
760
+ text: message.content,
761
+ finishReason: choice.finish_reason,
762
+ ...(usage ? { usage } : {}),
763
+ };
375
764
  }
376
765
  function createTransport(options) {
377
766
  if (options.fetch) {
@@ -410,6 +799,9 @@ function toChatCompletionsBody(options, environmentInput, turn, runId) {
410
799
  run_id: runId,
411
800
  ...(options.defaultMode ? { mode: options.defaultMode } : {}),
412
801
  ...(profile ? { agent_profile: profile } : {}),
802
+ ...(profile?.model?.reasoningEffort
803
+ ? { effort: profile.model.reasoningEffort }
804
+ : {}),
413
805
  ...(environmentInput.env ? { env: environmentInput.env } : {}),
414
806
  ...(environmentInput.workspace?.cwd ? { cwd: environmentInput.workspace.cwd } : {}),
415
807
  ...(executionFromInput(options, environmentInput) ? { execution: executionFromInput(options, environmentInput) } : {}),
@@ -649,7 +1041,7 @@ export function defaultCliBridgeCapabilities(harness) {
649
1041
  runtimeUpdate: false,
650
1042
  validation: false,
651
1043
  },
652
- streaming: { live: true, replay: true, detach: false, turnIdempotency: true },
1044
+ streaming: { live: true, replay: true, detach: true, turnIdempotency: true },
653
1045
  sessions: { continue: true, list: false, messages: false },
654
1046
  workspace: { read: false, write: false, exec: false, git: false, upload: false, download: false },
655
1047
  branching: { checkpoint: false, fork: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-cli-bridge",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "AgentEnvironmentProvider adapter for cli-bridge /v1/chat/completions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "dependencies": {
32
- "@tangle-network/agent-interface": "0.46.0",
32
+ "@tangle-network/agent-interface": "0.47.0",
33
33
  "undici": "7.28.0"
34
34
  },
35
35
  "devDependencies": {