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

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/dist/index.js CHANGED
@@ -1,10 +1,8 @@
1
- import { createHash } from "node:crypto";
2
1
  import { harnessSystemPromptIntents, snapshotAgentProfile, } from "@tangle-network/agent-interface";
3
- import { Agent, fetch as undiciFetch } from "undici";
2
+ import { createCliBridgeEnvironment } from "./retained-environment.js";
3
+ import { assertCliBridgeProviderOptions, } from "./provider-options.js";
4
4
  export function createCliBridgeProvider(options) {
5
- assertTimeout(options.headersTimeoutMs, "headersTimeoutMs");
6
- assertTimeout(options.bodyTimeoutMs, "bodyTimeoutMs");
7
- assertTimeout(options.cancelWaitMs, "cancelWaitMs");
5
+ assertCliBridgeProviderOptions(options);
8
6
  const name = options.name ?? "cli-bridge";
9
7
  return {
10
8
  name,
@@ -17,1006 +15,35 @@ export function createCliBridgeProvider(options) {
17
15
  ...input,
18
16
  profile: snapshotAgentProfile(input.profile),
19
17
  };
20
- const transport = createTransport(options);
21
18
  const environmentId = input.idempotencyKey ?? crypto.randomUUID();
22
- const runs = new Map();
23
- const sessions = new Map();
24
- const readers = new Set();
25
- let destroyed = false;
26
- let closePromise;
27
- const stream = async function* (turn) {
28
- if (destroyed)
29
- throw new Error("cli-bridge environment is destroyed");
30
- const prepared = prepareCliBridgeRun(options, environmentInput, turn, environmentId, false);
31
- yield* streamTrackedCliBridgeTurn(options, environmentInput, prepared, transport, runs, sessions, readers);
32
- };
33
- const environment = {
34
- id: environmentId,
35
- provider: name,
36
- ...(input.name ? { name: input.name } : {}),
37
- status: async () => (destroyed ? "stopped" : "running"),
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
- },
59
- placement: async () => ({
60
- kind: options.defaultExecution?.kind === "sandbox" ? "sandbox" : "local",
61
- providerMetadata: { baseUrl: options.baseUrl },
62
- }),
63
- async destroy() {
64
- if (closePromise)
65
- return closePromise;
66
- destroyed = true;
67
- let cancellationsConfirmed = false;
68
- const attempt = (async () => {
69
- const cancellations = await Promise.allSettled(Array.from(runs.values()).map(async (run) => {
70
- const snapshot = await cancelCliBridgeRun(options, transport, run);
71
- if (runs.get(run.id) === run)
72
- runs.delete(run.id);
73
- return snapshot;
74
- }));
75
- const failures = cancellations.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
76
- if (failures.length === 1)
77
- throw failures[0];
78
- if (failures.length > 1) {
79
- throw new AggregateError(failures, "cli-bridge environment cancellation failed");
80
- }
81
- cancellationsConfirmed = true;
82
- for (const reader of readers) {
83
- reader.abort(new DOMException("cli-bridge environment was destroyed", "AbortError"));
84
- }
85
- await transport.close();
86
- sessions.clear();
87
- })();
88
- closePromise = attempt;
89
- try {
90
- await attempt;
91
- }
92
- catch (error) {
93
- closePromise = undefined;
94
- if (!cancellationsConfirmed)
95
- destroyed = false;
96
- throw error;
97
- }
98
- },
99
- };
100
- return environment;
101
- },
102
- };
103
- }
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;
158
- if (originalTurn.detach) {
159
- throw new Error("cli-bridge provider does not support detached turns");
160
- }
161
- const controller = new AbortController();
162
- const signals = [
163
- originalTurn.signal,
164
- environmentInput.signal,
165
- controller.signal,
166
- ].filter((signal) => signal !== undefined);
167
- const turn = {
168
- ...originalTurn,
169
- ...(signals.length > 0 ? { signal: AbortSignal.any(signals) } : {}),
170
- };
171
- const run = prepared.run;
172
- const previousRun = bindCliBridgeRun(run, runs);
173
- const previousSessionRun = bindCliBridgeSession(run, sessions);
174
- run.readers.add(controller);
175
- readers.add(controller);
176
- let drained = false;
177
- let threw = false;
178
- try {
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))) {
180
- yield event;
181
- }
182
- drained = true;
183
- if (runs.get(run.id) === run)
184
- runs.delete(run.id);
185
- }
186
- catch (error) {
187
- threw = true;
188
- if (error instanceof CliBridgeRequestRejectedError) {
189
- restoreCliBridgeRun(run, previousRun, runs);
190
- restoreCliBridgeSession(run, previousSessionRun, sessions);
191
- throw error;
192
- }
193
- let snapshot;
194
- try {
195
- snapshot = await getCliBridgeRun(options, transport, run.id);
196
- }
197
- catch {
198
- snapshot = undefined;
199
- }
200
- if (snapshot?.terminal) {
201
- if (runs.get(run.id) === run)
202
- runs.delete(run.id);
203
- }
204
- else if ((originalTurn.signal?.aborted || environmentInput.signal?.aborted)) {
205
- await cancelCliBridgeRun(options, transport, run);
206
- if (runs.get(run.id) === run)
207
- runs.delete(run.id);
208
- }
209
- throw error;
210
- }
211
- finally {
212
- if (!drained && !threw && runs.get(run.id) === run) {
213
- await cancelCliBridgeRun(options, transport, run);
214
- runs.delete(run.id);
215
- }
216
- run.readers.delete(controller);
217
- readers.delete(controller);
218
- }
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
- }
513
- async function cancelCliBridgeRun(options, transport, run) {
514
- if (run.cancellation)
515
- return run.cancellation;
516
- run.cancellation = (async () => {
517
- const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(run.id)}/cancel`, {
518
- method: "POST",
519
- headers: requestHeaders(options),
520
- body: "{}",
521
- });
522
- if (!response.ok) {
523
- throw new Error(`cli-bridge cancel ${response.status}: ${await response.text()}`);
524
- }
525
- let snapshot = cancelSnapshot(await response.text());
526
- assertCliBridgeRunSnapshotIdentity(snapshot, run.id);
527
- const waitBudgetMs = options.cancelWaitMs ?? 30_000;
528
- const deadline = Date.now() + waitBudgetMs;
529
- while (!snapshot.terminal) {
530
- const remainingMs = deadline - Date.now();
531
- if (remainingMs <= 0)
532
- break;
533
- snapshot = await getCliBridgeRun(options, transport, run.id, Math.min(remainingMs, 30_000));
534
- if (snapshot === null) {
535
- throw new Error(`cli-bridge lost run "${run.id}" before confirming cancellation`);
536
- }
537
- }
538
- if (!snapshot?.terminal) {
539
- throw new Error(`cli-bridge run "${run.id}" did not confirm terminal cancellation`);
540
- }
541
- for (const reader of run.readers) {
542
- reader.abort(new DOMException(`cli-bridge run ended ${snapshot.status}`, "AbortError"));
543
- }
544
- return snapshot;
545
- })();
546
- try {
547
- return await run.cancellation;
548
- }
549
- finally {
550
- run.cancellation = undefined;
551
- }
552
- }
553
- async function getCliBridgeRun(options, transport, runId, waitMs) {
554
- const query = waitMs === undefined ? "" : `?wait_ms=${waitMs}`;
555
- const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(runId)}${query}`, {
556
- method: "GET",
557
- headers: requestHeaders(options),
558
- });
559
- if (response.status === 404)
560
- return null;
561
- if (!response.ok) {
562
- throw new Error(`cli-bridge run status ${response.status}: ${await response.text()}`);
563
- }
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
- }
572
- }
573
- async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onAccepted) {
574
- const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, {
575
- method: "POST",
576
- headers: {
577
- ...requestHeaders(options),
578
- accept: "text/event-stream",
579
- ...(turn.sessionId ? { "x-session-id": turn.sessionId } : {}),
580
- ...(lastEventId ? { "last-event-id": lastEventId } : {}),
581
- },
582
- body: requestBody,
583
- signal,
584
- });
585
- if (!response.ok) {
586
- let detail = "request rejected";
587
- try {
588
- detail = await response.text();
589
- }
590
- catch {
591
- // The HTTP status already proves this request was rejected.
592
- }
593
- throw new CliBridgeRequestRejectedError(response.status, detail);
594
- }
595
- onAccepted?.(response);
596
- if (!response.body)
597
- throw new Error("cli-bridge response body is empty");
598
- let text = "";
599
- const sessionId = turn.sessionId ?? runId;
600
- const messageId = turn.turnId ?? `${sessionId}:assistant`;
601
- const emittedToolCalls = new Set();
602
- let completed = false;
603
- let sawUsage = false;
604
- let terminalCursor;
605
- for await (const frame of parseSse(response.body)) {
606
- if (frame.data === "[DONE]")
607
- continue;
608
- const parsed = safeJson(frame.data);
609
- if (!parsed)
610
- continue;
611
- if (parsed.error && typeof parsed.error === "object") {
612
- const error = parsed.error;
613
- const message = typeof error.message === "string" ? error.message : "cli-bridge error";
614
- yield {
615
- type: "status",
616
- data: { status: "failed", error: message },
617
- ...(frame.id ? { id: frame.id } : {}),
618
- };
619
- throw new Error(`cli-bridge: ${message}`);
620
- }
621
- const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : undefined;
622
- const delta = choice?.delta;
623
- const chunk = delta && typeof delta.content === "string" ? delta.content : "";
624
- const nextUsage = usageFromOpenAi(parsed.usage);
625
- const frameEvents = [];
626
- if (nextUsage) {
627
- sawUsage = true;
628
- frameEvents.push({ type: "usage", data: {}, usage: nextUsage });
629
- }
630
- if (chunk) {
631
- text += chunk;
632
- const part = {
633
- id: `${messageId}:text`,
634
- sessionID: sessionId,
635
- messageID: messageId,
636
- type: "text",
637
- text,
638
- };
639
- const normalized = {
640
- type: "message.part.updated",
641
- part,
642
- delta: chunk,
643
- };
644
- frameEvents.push({
645
- type: "message.part.updated",
646
- data: { part, delta: chunk },
647
- normalized,
19
+ return createCliBridgeEnvironment({
20
+ options,
21
+ providerName: name,
22
+ environmentInput,
23
+ environmentId,
24
+ allowDispatch: true,
25
+ cancelRunsOnDestroy: true,
648
26
  });
649
- }
650
- for (const toolCall of toolCallsFromDelta(delta)) {
651
- const callId = toolCall.id ?? `${messageId}:tool:${toolCall.index}`;
652
- if (!toolCall.name || emittedToolCalls.has(callId))
653
- continue;
654
- emittedToolCalls.add(callId);
655
- const part = {
656
- id: callId,
657
- sessionID: sessionId,
658
- messageID: messageId,
659
- type: "tool",
660
- callID: callId,
661
- tool: toolCall.name,
662
- state: { status: "pending", input: {} },
663
- };
664
- const normalized = {
665
- type: "message.part.updated",
666
- part,
667
- };
668
- frameEvents.push({
669
- type: "message.part.updated",
670
- data: { part },
671
- normalized,
672
- });
673
- }
674
- if (choice?.finish_reason) {
675
- if (choice.finish_reason === "error") {
676
- frameEvents.push({
677
- type: "status",
678
- data: { status: "failed", error: "cli-bridge returned finish_reason=error" },
679
- });
680
- yield* eventsWithCursor(frameEvents, frame.id);
681
- throw new Error("cli-bridge returned finish_reason=error");
682
- }
683
- completed = true;
684
- if (lastEventId) {
685
- terminalCursor = frame.id;
686
- }
687
- else {
688
- frameEvents.push({
689
- type: "result",
690
- data: {
691
- finalText: text,
692
- finishReason: choice.finish_reason,
693
- status: "completed",
694
- },
695
- });
696
- }
697
- }
698
- yield* eventsWithCursor(frameEvents, choice?.finish_reason && lastEventId ? undefined : frame.id);
699
- }
700
- if (!completed && !lastEventId) {
701
- throw new Error("cli-bridge stream ended without a terminal result");
702
- }
703
- if (lastEventId) {
704
- const result = await readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted);
705
- if (result.usage && !sawUsage) {
706
- yield { type: "usage", data: {}, usage: result.usage };
707
- }
708
- yield {
709
- type: "result",
710
- data: {
711
- finalText: result.text,
712
- finishReason: result.finishReason,
713
- status: "completed",
714
- },
715
- id: terminalCursor ?? lastEventId,
716
- };
717
- }
718
- }
719
- class CliBridgeRequestRejectedError extends Error {
720
- status;
721
- constructor(status, detail) {
722
- super(`cli-bridge ${status}: ${detail}`);
723
- this.status = status;
724
- this.name = "CliBridgeRequestRejectedError";
725
- }
726
- }
727
- async function readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted) {
728
- const body = safeJson(requestBody);
729
- if (!body)
730
- throw new Error("cli-bridge replay request is not valid JSON");
731
- const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/chat/completions`, {
732
- method: "POST",
733
- headers: requestHeaders(options),
734
- body: JSON.stringify({ ...body, stream: false }),
735
- signal,
736
- });
737
- if (!response.ok) {
738
- throw new Error(`cli-bridge replay result ${response.status}: ${await response.text()}`);
739
- }
740
- onAccepted?.(response);
741
- const parsed = safeJson(await response.text());
742
- if (parsed?.error && typeof parsed.error === "object") {
743
- const error = parsed.error;
744
- const message = typeof error.message === "string" ? error.message : "cli-bridge replay failed";
745
- throw new Error(`cli-bridge replay result failed: ${message}`);
746
- }
747
- const choice = Array.isArray(parsed?.choices) ? parsed.choices[0] : undefined;
748
- const message = choice?.message && typeof choice.message === "object"
749
- ? choice.message
750
- : undefined;
751
- if (typeof message?.content !== "string" ||
752
- typeof choice?.finish_reason !== "string") {
753
- throw new Error("cli-bridge replay result returned an invalid completion");
754
- }
755
- if (choice.finish_reason === "error" || choice.finish_reason === "timeout") {
756
- throw new Error(`cli-bridge replay result ended ${choice.finish_reason}`);
757
- }
758
- const usage = usageFromOpenAi(parsed?.usage);
759
- return {
760
- text: message.content,
761
- finishReason: choice.finish_reason,
762
- ...(usage ? { usage } : {}),
763
- };
764
- }
765
- function createTransport(options) {
766
- if (options.fetch) {
767
- const fetch = options.fetch;
768
- return {
769
- fetch: (input, init) => fetch(input, init),
770
- close: async () => { },
771
- };
772
- }
773
- const dispatcher = new Agent({
774
- headersTimeout: options.headersTimeoutMs ?? 0,
775
- bodyTimeout: options.bodyTimeoutMs ?? 0,
776
- });
777
- return {
778
- fetch: (input, init) => undiciFetch(input, {
779
- ...init,
780
- dispatcher,
781
- }),
782
- close: async () => {
783
- await dispatcher.close();
784
27
  },
785
- };
786
- }
787
- function assertTimeout(value, name) {
788
- if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
789
- throw new Error(`createCliBridgeProvider ${name} must be a non-negative integer`);
790
- }
791
- }
792
- function toChatCompletionsBody(options, environmentInput, turn, runId) {
793
- const profile = inlineProfile(environmentInput.profile);
794
- return {
795
- model: resolveBridgeModel(options, environmentInput, turn, profile),
796
- messages: messagesFromTurn(turn),
797
- stream: true,
798
- ...(turn.sessionId ? { session_id: turn.sessionId } : {}),
799
- run_id: runId,
800
- ...(options.defaultMode ? { mode: options.defaultMode } : {}),
801
- ...(profile ? { agent_profile: profile } : {}),
802
- ...(profile?.model?.reasoningEffort
803
- ? { effort: profile.model.reasoningEffort }
804
- : {}),
805
- ...(environmentInput.env ? { env: environmentInput.env } : {}),
806
- ...(environmentInput.workspace?.cwd ? { cwd: environmentInput.workspace.cwd } : {}),
807
- ...(executionFromInput(options, environmentInput) ? { execution: executionFromInput(options, environmentInput) } : {}),
808
- metadata: {
809
- ...(environmentInput.metadata ?? {}),
810
- ...(turn.context ?? {}),
811
- ...(turn.providerOptions ?? {}),
28
+ async get(id) {
29
+ if (id.length === 0 || id.trim() !== id) {
30
+ throw new Error("cli-bridge environment id must be non-empty and have no outer whitespace");
31
+ }
32
+ return createCliBridgeEnvironment({
33
+ options,
34
+ providerName: name,
35
+ environmentInput: { profile: { name: "reconnected" }, idempotencyKey: id },
36
+ environmentId: id,
37
+ allowDispatch: false,
38
+ cancelRunsOnDestroy: false,
39
+ });
812
40
  },
813
41
  };
814
42
  }
815
- function resolveBridgeModel(options, environmentInput, turn, profile) {
816
- const harness = environmentInput.backend ?? profile?.harness;
817
- const model = turn.model ?? options.defaultModel ?? profile?.model?.default;
818
- const provider = profile?.model?.provider;
819
- if (!harness) {
820
- if (model)
821
- return model;
822
- throw new Error("createCliBridgeProvider requires an explicit bridge model or a profile/backend harness");
823
- }
824
- if (!model || model === harness)
825
- return harness;
826
- if (model.startsWith(`${harness}/`))
827
- return model;
828
- if (model.includes("/"))
829
- return `${harness}/${model}`;
830
- if (provider)
831
- return `${harness}/${provider}/${model}`;
832
- return `${harness}/${model}`;
833
- }
834
43
  /**
835
- * The turn's messages, and nothing else.
44
+ * Describe the bridge backend, not this adapter.
836
45
  *
837
- * No `role: "system"` message is synthesized from the profile. `prompt.systemPrompt` means DELETE
838
- * the harness's own prompt; lowering it here would have added it on top of that prompt instead —
839
- * the opposite intent, invisible to the caller. Both prompt intents ride `agent_profile` on the
840
- * same request (see {@link toChatCompletionsBody}), where the bridge binds each to the control its
841
- * harness actually owns or refuses it. The bridge additionally rejects any request that carries
842
- * system-role messages beside `agent_profile`, so synthesizing one here failed the request outright.
843
- */
844
- function messagesFromTurn(turn) {
845
- return [{ role: "user", content: contentFromTurn(turn) }];
846
- }
847
- function contentFromTurn(turn) {
848
- if (turn.parts)
849
- return turn.parts;
850
- return turn.prompt ?? "";
851
- }
852
- function inlineProfile(profile) {
853
- return typeof profile === "string" ? undefined : profile;
854
- }
855
- function executionFromInput(options, input) {
856
- if (options.defaultExecution)
857
- return options.defaultExecution;
858
- if (!input.workspace?.repoUrl)
859
- return undefined;
860
- return {
861
- kind: "sandbox",
862
- repoUrl: input.workspace.repoUrl,
863
- ...(input.workspace.gitRef ? { gitRef: input.workspace.gitRef } : {}),
864
- };
865
- }
866
- async function* parseSse(body) {
867
- const decoder = new TextDecoder();
868
- let buffer = "";
869
- for await (const value of body) {
870
- buffer += decoder.decode(value, { stream: true });
871
- let boundary = findFrameBoundary(buffer);
872
- while (boundary) {
873
- const frame = buffer.slice(0, boundary.index);
874
- buffer = buffer.slice(boundary.index + boundary.length);
875
- const data = dataFromFrame(frame);
876
- if (data)
877
- yield data;
878
- boundary = findFrameBoundary(buffer);
879
- }
880
- }
881
- if (buffer) {
882
- const data = dataFromFrame(buffer);
883
- if (data)
884
- yield data;
885
- }
886
- }
887
- function findFrameBoundary(value) {
888
- const lf = value.indexOf("\n\n");
889
- const crlf = value.indexOf("\r\n\r\n");
890
- if (lf === -1 && crlf === -1)
891
- return undefined;
892
- if (crlf !== -1 && (lf === -1 || crlf < lf))
893
- return { index: crlf, length: 4 };
894
- return { index: lf, length: 2 };
895
- }
896
- function dataFromFrame(frame) {
897
- const lines = frame.split(/\r?\n/);
898
- const data = lines
899
- .filter((line) => line.startsWith("data:"))
900
- .map((line) => line.slice("data:".length).trimStart())
901
- .join("\n");
902
- if (!data)
903
- return undefined;
904
- const id = lines.find((line) => line.startsWith("id:"))?.slice("id:".length).trim();
905
- return { data, ...(id ? { id } : {}) };
906
- }
907
- function* eventsWithCursor(events, cursor) {
908
- for (let index = 0; index < events.length; index += 1) {
909
- const event = events[index];
910
- yield cursor && index === events.length - 1 ? { ...event, id: cursor } : event;
911
- }
912
- }
913
- function toolCallsFromDelta(value) {
914
- if (!value || typeof value !== "object")
915
- return [];
916
- const calls = value.tool_calls;
917
- if (!Array.isArray(calls))
918
- return [];
919
- return calls.flatMap((call, position) => {
920
- if (!call || typeof call !== "object")
921
- return [];
922
- const record = call;
923
- const fn = record.function;
924
- const name = fn && typeof fn === "object" && typeof fn.name === "string"
925
- ? fn.name
926
- : undefined;
927
- const index = number(record.index) ?? position;
928
- const id = typeof record.id === "string" ? record.id : undefined;
929
- return [{ ...(id ? { id } : {}), index, ...(name ? { name } : {}) }];
930
- });
931
- }
932
- function safeJson(value) {
933
- try {
934
- const parsed = JSON.parse(value);
935
- return parsed && typeof parsed === "object" ? parsed : null;
936
- }
937
- catch {
938
- return null;
939
- }
940
- }
941
- function usageFromOpenAi(value) {
942
- if (!value || typeof value !== "object")
943
- return undefined;
944
- const record = value;
945
- const inputTokens = number(record.prompt_tokens) ?? number(record.input_tokens);
946
- const outputTokens = number(record.completion_tokens) ?? number(record.output_tokens);
947
- if (inputTokens === undefined && outputTokens === undefined)
948
- return undefined;
949
- const totalTokens = number(record.total_tokens);
950
- const cacheReadInputTokens = number(record.cache_read_input_tokens);
951
- const cacheCreationInputTokens = number(record.cache_creation_input_tokens);
952
- const reasoningTokens = number(record.reasoning_tokens);
953
- const cost = number(record.cost);
954
- return {
955
- inputTokens: inputTokens ?? 0,
956
- outputTokens: outputTokens ?? 0,
957
- ...(totalTokens !== undefined ? { totalTokens } : {}),
958
- ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
959
- ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
960
- ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
961
- ...(cost !== undefined ? { cost } : {}),
962
- };
963
- }
964
- function requestHeaders(options) {
965
- return {
966
- "content-type": "application/json",
967
- ...(options.bearerToken ? { authorization: `Bearer ${options.bearerToken}` } : {}),
968
- };
969
- }
970
- function cliBridgeRunId(environmentId, turn, turnId) {
971
- if (turn.executionId &&
972
- turn.executionId.length <= 128 &&
973
- /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(turn.executionId)) {
974
- return turn.executionId;
975
- }
976
- const digest = createHash("sha256")
977
- .update(environmentId)
978
- .update("\0")
979
- .update(turn.sessionId ?? "")
980
- .update("\0")
981
- .update(turn.executionId ?? turnId)
982
- .digest("hex");
983
- return `agent-${digest}`;
984
- }
985
- function runSnapshot(value) {
986
- const parsed = safeJson(value);
987
- if (!parsed)
988
- throw new Error("cli-bridge run status returned invalid JSON");
989
- const id = parsed.id;
990
- const status = parsed.status;
991
- const terminal = parsed.terminal;
992
- if (typeof id !== "string" ||
993
- !["running", "done", "error", "cancelled"].includes(String(status)) ||
994
- typeof terminal !== "boolean") {
995
- throw new Error("cli-bridge run status returned an invalid snapshot");
996
- }
997
- return {
998
- id,
999
- status: status,
1000
- terminal,
1001
- };
1002
- }
1003
- function cancelSnapshot(value) {
1004
- const parsed = safeJson(value);
1005
- if (!parsed || !parsed.run || typeof parsed.run !== "object") {
1006
- throw new Error("cli-bridge cancel returned an invalid snapshot");
1007
- }
1008
- return runSnapshot(JSON.stringify(parsed.run));
1009
- }
1010
- function number(value) {
1011
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1012
- }
1013
- function trimSlash(value) {
1014
- return value.replace(/\/+$/, "");
1015
- }
1016
- /**
1017
- * @param harness The bridge backend these capabilities describe. The prompt intents belong to that
1018
- * harness, not to this adapter — the adapter only forwards `agent_profile`. Omit it and both intents
1019
- * declare `false`: an adapter that cannot name its harness cannot promise either one.
46
+ * The adapter forwards an AgentProfile but does not own harness prompt controls.
1020
47
  */
1021
48
  export function defaultCliBridgeCapabilities(harness) {
1022
49
  return {
@@ -1043,7 +70,20 @@ export function defaultCliBridgeCapabilities(harness) {
1043
70
  },
1044
71
  streaming: { live: true, replay: true, detach: true, turnIdempotency: true },
1045
72
  sessions: { continue: true, list: false, messages: false },
1046
- workspace: { read: false, write: false, exec: false, git: false, upload: false, download: false },
73
+ retainedControl: {
74
+ exactRunIdentity: true,
75
+ resultIdentity: true,
76
+ eventIdentity: true,
77
+ cancellationIdempotency: true,
78
+ },
79
+ workspace: {
80
+ read: false,
81
+ write: false,
82
+ exec: false,
83
+ git: false,
84
+ upload: false,
85
+ download: false,
86
+ },
1047
87
  branching: { checkpoint: false, fork: false },
1048
88
  placement: true,
1049
89
  usage: true,