@tangle-network/agent-provider-cli-bridge 2.0.2 → 2.0.4
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 +33 -0
- package/dist/retained-canonical-event.d.ts +1 -1
- package/dist/retained-canonical-event.js +7 -33
- package/dist/retained-environment.js +2 -3
- package/dist/retained-execution.d.ts +2 -2
- package/dist/retained-execution.js +73 -55
- package/dist/retained-stream.d.ts +2 -0
- package/dist/retained-stream.js +71 -49
- package/dist/sandbox/box.d.ts +60 -0
- package/dist/sandbox/box.js +68 -0
- package/dist/sandbox/image.d.ts +9 -0
- package/dist/sandbox/image.js +31 -0
- package/dist/sandbox/index.d.ts +6 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/provider.d.ts +23 -0
- package/dist/sandbox/provider.js +473 -0
- package/dist/sandbox/refusal.d.ts +23 -0
- package/dist/sandbox/refusal.js +159 -0
- package/dist/sandbox/vendor.d.ts +105 -0
- package/dist/sandbox/vendor.js +1 -0
- package/dist/wire.d.ts +4 -1
- package/dist/wire.js +43 -29
- package/package.json +24 -5
package/README.md
CHANGED
|
@@ -104,6 +104,21 @@ The provider uses the explicit SSE event field to select the format and rejects
|
|
|
104
104
|
The provider rejects a terminal response when its run id or request digest does not match the accepted stream.
|
|
105
105
|
Usage events and terminal result metadata include `modelRequests` only when the bridge measured an exact non-negative integer count.
|
|
106
106
|
Missing, malformed, partial, or estimated request counts remain unknown.
|
|
107
|
+
|
|
108
|
+
`metadata.model` and event `data.model` retain Bridge's compatibility response value; they do not prove which model served a request.
|
|
109
|
+
Explicit provider-response observations appear as `provider_models` in event data and result metadata.
|
|
110
|
+
`provider_model` is present only for one distinct observation, and never on a failed result.
|
|
111
|
+
The collector retains disagreements independently of its bounded result event history; overflow or malformed evidence refuses certification.
|
|
112
|
+
Requested routes, init records, fingerprints, configured session models and application JSON never establish provider identity.
|
|
113
|
+
Opaque native records remain in their existing envelope and cannot promote their own fields into host evidence.
|
|
114
|
+
|
|
115
|
+
On the qualified OpenAI wire path, raw Bridge usage is retained in `event.providerEvent.usage` even when a partial receipt cannot form `TokenUsage`.
|
|
116
|
+
`metadata.provider_usage` folds those receipts outside the bounded event history: a missing count stays unknown across later deltas, and a cumulative receipt replaces counts and coverage.
|
|
117
|
+
Events declare this distinction with the existing `usageMode` field.
|
|
118
|
+
Measured usage requires explicit `estimated: false` and valid reported input and output totals; `estimated: true` is an estimate, and absent provenance remains unknown.
|
|
119
|
+
Observed zero is retained. Missing cache classes and cost remain unknown; `cost_scope` applies only to cost.
|
|
120
|
+
The numeric `result.usage` compatibility projection alone does not certify complete measured usage.
|
|
121
|
+
Old peers and opaque canonical native envelopes remain readable with uncertified provenance; nested native or application records cannot assert host observation authority.
|
|
107
122
|
When `dispatch()` receives no `sessionId`, it creates one from that turn's stable run id and returns it.
|
|
108
123
|
Replay and result reads fail loudly after cli-bridge's configured replay retention expires.
|
|
109
124
|
Stopping a `session.events()` reader detaches only that replay observer.
|
|
@@ -180,3 +195,21 @@ Those five surfaces are never claimed in the capability document and always carr
|
|
|
180
195
|
The usage of one run is summed across its events and attributed to that run's execution id, which the observation carries as the provenance source.
|
|
181
196
|
An environment rebuilt with `provider.get(id)` has measured nothing yet, so it reports the usage surface as unavailable until a turn completes through it.
|
|
182
197
|
The endpoint holds only a scheme, a host, and an explicit port; userinfo, path, and query are dropped, and the bearer beside the base URL is never read.
|
|
198
|
+
|
|
199
|
+
## Cloud sandboxes (`./sandbox`)
|
|
200
|
+
|
|
201
|
+
`@tangle-network/agent-provider-cli-bridge/sandbox` turns a vendor sandbox that runs CLI Bridge into an `AgentEnvironmentProvider`.
|
|
202
|
+
The vendor adapter implements `BridgeSandboxVendor`: `create`, `connect`, `list`, and per sandbox `exec`, `spawn`, `read`, `write`, `endpoint`, `status` and `destroy`.
|
|
203
|
+
`createBridgeSandboxProvider({ vendor, router: { key } })` does the rest:
|
|
204
|
+
|
|
205
|
+
- It refuses every create field the vendor cannot honor with an `EnvironmentProviderError` that names the field, before any vendor call, and validates the profile with the materializer the Bridge in the box uses.
|
|
206
|
+
- It creates the sandbox from the box image with a finite lifetime and a run tag, starts the Bridge with a random bearer and the per-run Router key on that process only, waits for the selected backend to report ready, checks the harness CLI against the registry's pin, clones `workspace.repoUrl`, and creates the environment through `createCliBridgeProvider` at the exposed URL.
|
|
207
|
+
- Any failure after the vendor create destroys the sandbox before the error surfaces; a destroy that fails carries the sandbox id as `cleanupHandle`.
|
|
208
|
+
- Turns run through the Bridge. `turn.timeoutMs` cancels the run and ends the stream with a failed `result` that names the timeout, an abort rejects with `AbortError`, and a second turn on a busy environment is refused with `conflict`.
|
|
209
|
+
- Events carry `execution.started` with the declared model and a terminal `result` with the model the harness reported serving. The Bridge's dollar figure is the harness's catalog estimate, so events omit `usage.cost`.
|
|
210
|
+
|
|
211
|
+
The box image is `renderBoxDockerfile()`: Node, CLI Bridge at the commit in `BOX_PINS`, and each harness CLI at the version `@tangle-network/cli-agent-registry` pins.
|
|
212
|
+
A harness joins `BOX_HARNESSES` only after a live turn through the Router completed in a box; `claude-code` is the proven row.
|
|
213
|
+
Build the image for a cloud with `pnpm build:sandbox-image --target e2b|daytona|docker` from the repository root.
|
|
214
|
+
`@tangle-network/agent-provider-testkit` runs the live profile conformance suite against the result.
|
|
215
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type StreamEvent } from "@tangle-network/agent-interface";
|
|
2
2
|
import type { AgentEnvironmentEvent } from "@tangle-network/agent-interface/environment-provider";
|
|
3
|
-
import type
|
|
3
|
+
import { type CliBridgeSseFrame } from "./wire.js";
|
|
4
4
|
interface CanonicalEventContext {
|
|
5
5
|
readonly runId: string;
|
|
6
6
|
readonly sessionId: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RuntimeEventEnvelopeSchema, } from "@tangle-network/agent-interface";
|
|
2
|
+
import { providerUsageFromOpenAi as recordValue, usageFromOpenAi } from "./wire.js";
|
|
2
3
|
/** Decode one explicitly typed retained-run frame without guessing its wire format. */
|
|
3
4
|
export function retainedCanonicalEvent(frame, context) {
|
|
4
5
|
if (frame.event === undefined)
|
|
@@ -82,39 +83,12 @@ function usageFromCanonicalEvent(event) {
|
|
|
82
83
|
const usage = recordValue(providerEvent.usage) ?? recordValue(data?.usage);
|
|
83
84
|
if (!usage)
|
|
84
85
|
return undefined;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
outputTokens,
|
|
92
|
-
...optionalInteger(usage, "totalTokens"),
|
|
93
|
-
...optionalInteger(usage, "cacheReadInputTokens"),
|
|
94
|
-
...optionalInteger(usage, "cacheCreationInputTokens"),
|
|
95
|
-
...optionalInteger(usage, "reasoningTokens"),
|
|
96
|
-
...optionalNumber(usage, "cost"),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
function optionalInteger(source, key) {
|
|
100
|
-
const value = nonNegativeInteger(source[key]);
|
|
101
|
-
return value === undefined ? {} : { [key]: value };
|
|
102
|
-
}
|
|
103
|
-
function optionalNumber(source, key) {
|
|
104
|
-
const value = source[key];
|
|
105
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
106
|
-
? { [key]: value }
|
|
107
|
-
: {};
|
|
108
|
-
}
|
|
109
|
-
function nonNegativeInteger(value) {
|
|
110
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
111
|
-
? value
|
|
112
|
-
: undefined;
|
|
113
|
-
}
|
|
114
|
-
function recordValue(value) {
|
|
115
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
116
|
-
? value
|
|
117
|
-
: undefined;
|
|
86
|
+
return usageFromOpenAi({
|
|
87
|
+
prompt_tokens: usage.inputTokens, completion_tokens: usage.outputTokens,
|
|
88
|
+
total_tokens: usage.totalTokens, cache_read_input_tokens: usage.cacheReadInputTokens,
|
|
89
|
+
cache_write_input_tokens: usage.cacheCreationInputTokens, reasoning_tokens: usage.reasoningTokens,
|
|
90
|
+
cost: usage.cost,
|
|
91
|
+
});
|
|
118
92
|
}
|
|
119
93
|
function isBaseTenInteger(value) {
|
|
120
94
|
return /^(0|[1-9][0-9]*)$/u.test(value) && Number.isSafeInteger(Number(value));
|
|
@@ -46,11 +46,10 @@ export function createCliBridgeEnvironment(args) {
|
|
|
46
46
|
}
|
|
47
47
|
assertCliBridgeRequestedInteractions(args.capabilities, turn.interactions);
|
|
48
48
|
const prepared = prepareCliBridgeRun(options, environmentInput, turn, providerName, environmentId, useNativeInteractions, args.acceptedContextTransfer);
|
|
49
|
-
//
|
|
50
|
-
// here and recorded as one execution's measurement.
|
|
49
|
+
// Delta receipts add; cumulative receipts replace this execution's measurement.
|
|
51
50
|
let streamedUsage;
|
|
52
51
|
for await (const event of streamTrackedCliBridgeTurn(options, environmentInput, providerName, prepared, transport, runs, sessions, readers, nativeSessions, useNativeInteractions)) {
|
|
53
|
-
streamedUsage = addTokenUsage(streamedUsage, event.usage);
|
|
52
|
+
streamedUsage = addTokenUsage(streamedUsage, event.usage, event.usageMode);
|
|
54
53
|
usageLog.record(prepared.run.executionId, streamedUsage);
|
|
55
54
|
yield event;
|
|
56
55
|
}
|
|
@@ -14,5 +14,5 @@ export declare function streamCliBridgeSessionEvents(options: CliBridgeProviderO
|
|
|
14
14
|
export declare function collectCliBridgeTurnResult(source: AsyncIterable<AgentEnvironmentEvent>, run: CliBridgeRun, options: CliBridgeProviderOptions, transport: CliBridgeTransport, signal?: AbortSignal): Promise<AgentTurnResult>;
|
|
15
15
|
/** Keep reasoning and tool updates out of the assistant's visible result text. */
|
|
16
16
|
export declare function visibleTextDelta(event: AgentEnvironmentEvent): string | undefined;
|
|
17
|
-
/**
|
|
18
|
-
export declare function addTokenUsage(current: TokenUsage | undefined, next: TokenUsage | undefined): TokenUsage | undefined;
|
|
17
|
+
/** Respect cumulative receipts and keep missing optional classes unknown. */
|
|
18
|
+
export declare function addTokenUsage(current: TokenUsage | undefined, next: TokenUsage | undefined, mode?: "delta" | "cumulative"): TokenUsage | undefined;
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { AgentTurnResultSchema, } from "@tangle-network/agent-interface";
|
|
1
|
+
import { AgentTurnResultSchema, CONTRACT_MAX_ARRAY_LENGTH, } from "@tangle-network/agent-interface";
|
|
2
2
|
import { cancelCliBridgeRun, captureCliBridgeRunIdentity, cliBridgeCancellationSignal, detachCliBridgeReader, getCliBridgeRun, } from "./retained-control.js";
|
|
3
3
|
import { bindCliBridgeRun, bindCliBridgeSession, exactControlRefForRun, restoreCliBridgeRun, restoreCliBridgeSession, } from "./retained-run-state.js";
|
|
4
|
-
import { CliBridgeRequestRejectedError, streamCliBridgeTurn, } from "./retained-stream.js";
|
|
4
|
+
import { CliBridgeRequestRejectedError, openCliBridgeStream, streamCliBridgeTurn, } from "./retained-stream.js";
|
|
5
5
|
import { beginCliBridgeNativeTurn, } from "./retained-native.js";
|
|
6
|
-
import {
|
|
7
|
-
import { modelRequestCount } from "./wire.js";
|
|
6
|
+
import { modelRequestCount, providerUsageFromOpenAi, responseIdentityFromOpenAi, usageFromOpenAi } from "./wire.js";
|
|
8
7
|
import { lookupCliBridgeContext } from "./context-transfer.js";
|
|
9
8
|
const MAX_CLI_BRIDGE_RESULT_EVENTS = 1_024;
|
|
10
9
|
export async function* streamTrackedCliBridgeTurn(options, environmentInput, providerName, prepared, transport, runs, sessions, readers, nativeSessions, useNativeInteractions) {
|
|
@@ -119,26 +118,9 @@ export async function dispatchCliBridgeTurn(options, environmentInput, prepared,
|
|
|
119
118
|
});
|
|
120
119
|
}
|
|
121
120
|
else {
|
|
122
|
-
const response = await
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
...requestHeaders(options),
|
|
126
|
-
accept: "text/event-stream",
|
|
127
|
-
...(run.sessionId ? { "x-session-id": run.sessionId } : {}),
|
|
128
|
-
},
|
|
129
|
-
body: run.requestBody,
|
|
130
|
-
...(signal ? { signal } : {}),
|
|
131
|
-
});
|
|
132
|
-
if (!response.ok) {
|
|
133
|
-
let detail = "request rejected";
|
|
134
|
-
try {
|
|
135
|
-
detail = await readBoundedCliBridgeResponse(response, MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, signal);
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
// The HTTP status already proves this request was rejected.
|
|
139
|
-
}
|
|
140
|
-
throw new CliBridgeRequestRejectedError(response.status, detail);
|
|
141
|
-
}
|
|
121
|
+
const response = await openCliBridgeStream([
|
|
122
|
+
options, { ...prepared.turn, sessionId: run.sessionId }, run.requestBody, transport, run.id, undefined, signal,
|
|
123
|
+
]);
|
|
142
124
|
accepted = true;
|
|
143
125
|
if (!response.body)
|
|
144
126
|
throw new Error("cli-bridge response body is empty");
|
|
@@ -261,6 +243,9 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
|
|
|
261
243
|
let eventCount = 0;
|
|
262
244
|
let text = "";
|
|
263
245
|
let usage;
|
|
246
|
+
let providerUsage;
|
|
247
|
+
let providerTokensSeen = false;
|
|
248
|
+
const providerModels = new Set();
|
|
264
249
|
let observedModelRequests = 0;
|
|
265
250
|
let modelRequestsKnown = false;
|
|
266
251
|
let terminalModelRequests;
|
|
@@ -283,7 +268,20 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
|
|
|
283
268
|
if (delta !== undefined)
|
|
284
269
|
text += delta;
|
|
285
270
|
}
|
|
286
|
-
|
|
271
|
+
const observedUsage = providerUsageFromOpenAi(providerUsageFromOpenAi(event.providerEvent)?.usage);
|
|
272
|
+
const rawUsage = observedUsage ??
|
|
273
|
+
(event.usage ? { prompt_tokens: event.usage.inputTokens, completion_tokens: event.usage.outputTokens } : undefined);
|
|
274
|
+
if (rawUsage) {
|
|
275
|
+
providerUsage = foldProviderUsage(providerUsage, rawUsage, event.usageMode, providerTokensSeen);
|
|
276
|
+
providerTokensSeen = event.usageMode === "cumulative"
|
|
277
|
+
? hasTokenReceipt(rawUsage) : providerTokensSeen || hasTokenReceipt(rawUsage);
|
|
278
|
+
}
|
|
279
|
+
usage = observedUsage ? usageFromOpenAi(providerUsage) : addTokenUsage(usage, event.usage, event.usageMode);
|
|
280
|
+
for (const model of responseIdentityFromOpenAi(event.data)?.provider_models ?? []) {
|
|
281
|
+
providerModels.add(model);
|
|
282
|
+
if (providerModels.size > CONTRACT_MAX_ARRAY_LENGTH)
|
|
283
|
+
throw new Error("cli-bridge provider model observations exceed contract bounds");
|
|
284
|
+
}
|
|
287
285
|
const eventModel = event.data.model;
|
|
288
286
|
const eventFingerprint = event.data.system_fingerprint;
|
|
289
287
|
if (typeof eventModel === "string" && eventModel.length > 0)
|
|
@@ -350,6 +348,9 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
|
|
|
350
348
|
? { modelRequests: observedModelRequests }
|
|
351
349
|
: {}),
|
|
352
350
|
...(servedModel === undefined ? {} : { model: servedModel }),
|
|
351
|
+
...(providerModels.size ? { provider_models: [...providerModels] } : {}),
|
|
352
|
+
...(success && providerModels.size === 1 ? { provider_model: [...providerModels][0] } : {}),
|
|
353
|
+
...(providerUsage === undefined ? {} : { provider_usage: providerUsage }),
|
|
353
354
|
...(systemFingerprint === undefined ? {} : { system_fingerprint: systemFingerprint }),
|
|
354
355
|
},
|
|
355
356
|
events: resultEvents,
|
|
@@ -374,39 +375,56 @@ export function visibleTextDelta(event) {
|
|
|
374
375
|
}
|
|
375
376
|
return typeof event.data.delta === "string" ? event.data.delta : undefined;
|
|
376
377
|
}
|
|
377
|
-
/**
|
|
378
|
-
export function addTokenUsage(current, next) {
|
|
378
|
+
/** Respect cumulative receipts and keep missing optional classes unknown. */
|
|
379
|
+
export function addTokenUsage(current, next, mode) {
|
|
380
|
+
if (mode === "cumulative")
|
|
381
|
+
return next ? { ...next } : undefined;
|
|
379
382
|
if (!next)
|
|
380
383
|
return current;
|
|
381
384
|
if (!current)
|
|
382
385
|
return { ...next };
|
|
383
|
-
return {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
386
|
+
return Object.fromEntries(Object.entries(next).flatMap(([key, count]) => {
|
|
387
|
+
const prior = current[key];
|
|
388
|
+
return prior === undefined ? [] : [[key, prior + count]];
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
const TOKEN_FIELDS = ["prompt_tokens", "input_tokens", "completion_tokens", "output_tokens", "total_tokens",
|
|
392
|
+
"fresh_input_tokens", "cache_read_input_tokens", "cache_write_input_tokens", "cache_creation_input_tokens", "reasoning_tokens"];
|
|
393
|
+
const hasTokenReceipt = (usage) => TOKEN_FIELDS.some(key => key in usage) || "estimated" in usage;
|
|
394
|
+
/** Coverage lives outside the bounded event ring: a later delta cannot fill an earlier omission. */
|
|
395
|
+
function foldProviderUsage(current, next, mode, hasPriorTokens) {
|
|
396
|
+
if (!current || mode === "cumulative")
|
|
397
|
+
return { ...next };
|
|
398
|
+
const folded = { ...current, ...next };
|
|
399
|
+
if (hasTokenReceipt(next)) {
|
|
400
|
+
for (const key of [...TOKEN_FIELDS, "model_requests"]) {
|
|
401
|
+
const value = next[key], prior = current[key];
|
|
402
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 &&
|
|
403
|
+
(!hasPriorTokens || (typeof prior === "number" && Number.isSafeInteger(prior) && prior >= 0))) {
|
|
404
|
+
const count = value + (hasPriorTokens ? prior : 0);
|
|
405
|
+
if (Number.isSafeInteger(count)) {
|
|
406
|
+
folded[key] = count;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
406
409
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
:
|
|
411
|
-
|
|
410
|
+
delete folded[key];
|
|
411
|
+
}
|
|
412
|
+
const estimated = current.estimated === true || next.estimated === true ? true :
|
|
413
|
+
next.estimated === false && (!hasPriorTokens || current.estimated === false) ? false : undefined;
|
|
414
|
+
if (estimated === undefined)
|
|
415
|
+
delete folded.estimated;
|
|
416
|
+
else
|
|
417
|
+
folded.estimated = estimated;
|
|
418
|
+
}
|
|
419
|
+
if (next.cost_scope !== "total") {
|
|
420
|
+
const cost = current.cost, delta = next.cost;
|
|
421
|
+
if (current.cost_known === true && next.cost_known === true && typeof cost === "number" && typeof delta === "number") {
|
|
422
|
+
folded.cost = cost + delta;
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
delete folded.cost;
|
|
426
|
+
folded.cost_known = false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return folded;
|
|
412
430
|
}
|
|
@@ -7,3 +7,5 @@ export declare class CliBridgeRequestRejectedError extends Error {
|
|
|
7
7
|
constructor(status: number, detail: string);
|
|
8
8
|
}
|
|
9
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>, validateCanonicalEvent?: (event: AgentEnvironmentEvent) => void): AsyncIterable<AgentEnvironmentEvent>;
|
|
10
|
+
/** Both dispatch and live readers use the same admission and bounded HTTP refusal. */
|
|
11
|
+
export declare function openCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse]: Parameters<typeof streamCliBridgeTurn>): Promise<CliBridgeResponse>;
|
package/dist/retained-stream.js
CHANGED
|
@@ -2,7 +2,7 @@ import { retainedEventsWithIdentity, retainedReplayRequest, } from "./retained-e
|
|
|
2
2
|
import { isTerminalCanonicalEvent, retainedCanonicalEvent, } from "./retained-canonical-event.js";
|
|
3
3
|
import { detachCliBridgeReader } from "./retained-control.js";
|
|
4
4
|
import { MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, MAX_CLI_BRIDGE_RESULT_RESPONSE_BYTES, readBoundedCliBridgeResponse, requestHeaders, trimSlash, } from "./transport.js";
|
|
5
|
-
import { modelRequestsFromOpenAi, parseSse, responseIdentityFromOpenAi, safeJson, toolCallsFromDelta, usageFromOpenAi, } from "./wire.js";
|
|
5
|
+
import { modelRequestsFromOpenAi, parseSse, providerUsageFromOpenAi, responseIdentityFromOpenAi, safeJson, toolCallsFromDelta, usageFromOpenAi, } from "./wire.js";
|
|
6
6
|
export class CliBridgeRequestRejectedError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
constructor(status, detail) {
|
|
@@ -20,47 +20,8 @@ class CliBridgeRunCancelledError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
export async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun, validateCanonicalEvent) {
|
|
23
|
-
const reconnect = requestBody === undefined;
|
|
24
23
|
const replay = retainedReplayRequest(lastEventId);
|
|
25
|
-
const response = await transport
|
|
26
|
-
? `${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(runId)}/events`
|
|
27
|
-
: `${trimSlash(options.baseUrl)}/v1/chat/completions`, {
|
|
28
|
-
method: reconnect ? "GET" : "POST",
|
|
29
|
-
headers: {
|
|
30
|
-
...requestHeaders(options),
|
|
31
|
-
accept: "text/event-stream",
|
|
32
|
-
...(!reconnect && turn.sessionId ? { "x-session-id": turn.sessionId } : {}),
|
|
33
|
-
...(replay.serverCursor === undefined
|
|
34
|
-
? {}
|
|
35
|
-
: { "last-event-id": replay.serverCursor }),
|
|
36
|
-
},
|
|
37
|
-
...(requestBody === undefined ? {} : { body: requestBody }),
|
|
38
|
-
...(signal ? { signal } : {}),
|
|
39
|
-
});
|
|
40
|
-
if (!response.ok) {
|
|
41
|
-
let detail = "request rejected";
|
|
42
|
-
try {
|
|
43
|
-
detail = await readBoundedCliBridgeResponse(response, MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, signal);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// The HTTP status already proves this request was rejected.
|
|
47
|
-
}
|
|
48
|
-
throw new CliBridgeRequestRejectedError(response.status, detail);
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
onResponse?.(response);
|
|
52
|
-
}
|
|
53
|
-
catch (error) {
|
|
54
|
-
if (response.body) {
|
|
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
|
-
}
|
|
62
|
-
throw error;
|
|
63
|
-
}
|
|
24
|
+
const response = await openCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse]);
|
|
64
25
|
if (!response.body)
|
|
65
26
|
throw new Error("cli-bridge response body is empty");
|
|
66
27
|
let text = "";
|
|
@@ -68,7 +29,6 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
68
29
|
const messageId = turn.turnId ?? `${sessionId}:assistant`;
|
|
69
30
|
const emittedToolCalls = new Set();
|
|
70
31
|
let completed = false;
|
|
71
|
-
let sawUsage = false;
|
|
72
32
|
let observedModelRequests = 0;
|
|
73
33
|
let modelRequestsKnown = false;
|
|
74
34
|
let servedModel;
|
|
@@ -156,6 +116,8 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
156
116
|
systemFingerprint = responseIdentity.system_fingerprint;
|
|
157
117
|
}
|
|
158
118
|
const identityData = {
|
|
119
|
+
...(responseIdentity?.provider_models ? { provider_models: responseIdentity.provider_models } : {}),
|
|
120
|
+
...(responseIdentity?.provider_model ? { provider_model: responseIdentity.provider_model } : {}),
|
|
159
121
|
...(servedModel === undefined ? {} : { model: servedModel }),
|
|
160
122
|
...(systemFingerprint === undefined ? {} : { system_fingerprint: systemFingerprint }),
|
|
161
123
|
};
|
|
@@ -171,12 +133,11 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
171
133
|
: rawChunk;
|
|
172
134
|
if (rawChunk && toolCallDeltas.length === 0)
|
|
173
135
|
textBoundaryPending = false;
|
|
174
|
-
const
|
|
136
|
+
const rawUsage = providerUsageFromOpenAi(parsed.usage);
|
|
137
|
+
const nextUsage = usageFromOpenAi(rawUsage);
|
|
175
138
|
const nextModelRequests = modelRequestsFromOpenAi(parsed.usage);
|
|
176
139
|
const frameEvents = [];
|
|
177
|
-
if (
|
|
178
|
-
if (nextUsage)
|
|
179
|
-
sawUsage = true;
|
|
140
|
+
if (rawUsage || nextModelRequests !== undefined) {
|
|
180
141
|
if (nextModelRequests !== undefined) {
|
|
181
142
|
observedModelRequests += nextModelRequests;
|
|
182
143
|
modelRequestsKnown = true;
|
|
@@ -188,6 +149,8 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
188
149
|
...(nextModelRequests === undefined ? {} : { modelRequests: nextModelRequests }),
|
|
189
150
|
},
|
|
190
151
|
...(nextUsage ? { usage: nextUsage } : {}),
|
|
152
|
+
usageMode: "delta",
|
|
153
|
+
providerEvent: { usage: rawUsage },
|
|
191
154
|
});
|
|
192
155
|
}
|
|
193
156
|
if (chunk) {
|
|
@@ -236,6 +199,9 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
236
199
|
}
|
|
237
200
|
if (toolCallDeltas.length > 0)
|
|
238
201
|
textBoundaryPending = true;
|
|
202
|
+
if (responseIdentity?.provider_models && !frameEvents.some(event => event.data.provider_models !== undefined)) {
|
|
203
|
+
frameEvents.push({ type: "raw", data: identityData, providerEvent: parsed });
|
|
204
|
+
}
|
|
239
205
|
if (choice?.finish_reason) {
|
|
240
206
|
terminalFrameSeen = true;
|
|
241
207
|
if (choice.finish_reason === "error") {
|
|
@@ -261,6 +227,58 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
261
227
|
}
|
|
262
228
|
yield* retainedEventsWithIdentity(frameEvents, frame.id, runId, sessionId, turn.executionId, replay.anchor);
|
|
263
229
|
}
|
|
230
|
+
yield* finishCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun], { protocol, canonicalTerminalStatus, canonicalTerminalEvent, sawProtocolEnd, completed,
|
|
231
|
+
cancelledTerminalSeen, terminalFrame, text, modelRequestsKnown, observedModelRequests, servedModel, systemFingerprint }, replay.anchor);
|
|
232
|
+
}
|
|
233
|
+
/** Both dispatch and live readers use the same admission and bounded HTTP refusal. */
|
|
234
|
+
export async function openCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse]) {
|
|
235
|
+
const reconnect = requestBody === undefined;
|
|
236
|
+
const replay = retainedReplayRequest(lastEventId);
|
|
237
|
+
const response = await transport.fetch(reconnect
|
|
238
|
+
? `${trimSlash(options.baseUrl)}/v1/runs/${encodeURIComponent(runId)}/events`
|
|
239
|
+
: `${trimSlash(options.baseUrl)}/v1/chat/completions`, {
|
|
240
|
+
method: reconnect ? "GET" : "POST",
|
|
241
|
+
headers: {
|
|
242
|
+
...requestHeaders(options),
|
|
243
|
+
accept: "text/event-stream",
|
|
244
|
+
...(!reconnect && turn.sessionId ? { "x-session-id": turn.sessionId } : {}),
|
|
245
|
+
...(replay.serverCursor === undefined
|
|
246
|
+
? {}
|
|
247
|
+
: { "last-event-id": replay.serverCursor }),
|
|
248
|
+
},
|
|
249
|
+
...(requestBody === undefined ? {} : { body: requestBody }),
|
|
250
|
+
...(signal ? { signal } : {}),
|
|
251
|
+
});
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
let detail = "request rejected";
|
|
254
|
+
try {
|
|
255
|
+
detail = await readBoundedCliBridgeResponse(response, MAX_CLI_BRIDGE_CONTROL_RESPONSE_BYTES, signal);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// The HTTP status already proves this request was rejected.
|
|
259
|
+
}
|
|
260
|
+
throw new CliBridgeRequestRejectedError(response.status, detail);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
onResponse?.(response);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (response.body) {
|
|
267
|
+
try {
|
|
268
|
+
await detachCliBridgeReader(response.body);
|
|
269
|
+
}
|
|
270
|
+
catch (detachError) {
|
|
271
|
+
throw new AggregateError([error, detachError], `cli-bridge rejected response identity for run "${runId}" and could not detach its reader`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
return response;
|
|
277
|
+
}
|
|
278
|
+
/** Confirm terminal authority before releasing the final frame and its cumulative receipt. */
|
|
279
|
+
async function* finishCliBridgeStream([options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun], { protocol, canonicalTerminalStatus, canonicalTerminalEvent, sawProtocolEnd, completed, cancelledTerminalSeen, terminalFrame, text, modelRequestsKnown, observedModelRequests, servedModel, systemFingerprint }, anchor) {
|
|
280
|
+
const sessionId = turn.sessionId ?? runId;
|
|
281
|
+
const reconnect = requestBody === undefined;
|
|
264
282
|
if (protocol === "canonical") {
|
|
265
283
|
if (canonicalTerminalStatus === undefined) {
|
|
266
284
|
throw new Error("cli-bridge canonical stream ended without terminal status");
|
|
@@ -359,7 +377,7 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
359
377
|
finalText: lastEventId === undefined ? text : result.text,
|
|
360
378
|
finishReason: lastEventId === undefined ? terminalFrame.finishReason : result.finishReason,
|
|
361
379
|
status: "completed",
|
|
362
|
-
...
|
|
380
|
+
...result.identity,
|
|
363
381
|
...(result.system_fingerprint === undefined
|
|
364
382
|
? {}
|
|
365
383
|
: { system_fingerprint: result.system_fingerprint }),
|
|
@@ -367,7 +385,9 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
367
385
|
? {}
|
|
368
386
|
: { modelRequests: finalModelRequests }),
|
|
369
387
|
},
|
|
370
|
-
...(result.usage
|
|
388
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
389
|
+
...(result.providerUsage ? { usageMode: "cumulative" } : {}),
|
|
390
|
+
...(result.providerUsage ? { providerEvent: { usage: result.providerUsage } } : {}),
|
|
371
391
|
};
|
|
372
392
|
}
|
|
373
393
|
}
|
|
@@ -401,7 +421,7 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
|
|
|
401
421
|
throw new Error("cli-bridge terminal state did not produce a terminal event");
|
|
402
422
|
}
|
|
403
423
|
terminalFrame.events.push(terminalEvent);
|
|
404
|
-
yield* retainedEventsWithIdentity(terminalFrame.events, terminalFrame.id, runId, sessionId, turn.executionId,
|
|
424
|
+
yield* retainedEventsWithIdentity(terminalFrame.events, terminalFrame.id, runId, sessionId, turn.executionId, anchor);
|
|
405
425
|
if (terminalFailure)
|
|
406
426
|
throw terminalFailure;
|
|
407
427
|
}
|
|
@@ -476,6 +496,8 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal,
|
|
|
476
496
|
text: message.content,
|
|
477
497
|
finishReason: choice.finish_reason,
|
|
478
498
|
...(usage ? { usage } : {}),
|
|
499
|
+
providerUsage: providerUsageFromOpenAi(parsed?.usage),
|
|
500
|
+
identity: responseIdentity,
|
|
479
501
|
...(modelRequests === undefined ? {} : { modelRequests }),
|
|
480
502
|
...(responseIdentity?.model === undefined ? {} : { model: responseIdentity.model }),
|
|
481
503
|
...(responseIdentity?.system_fingerprint === undefined
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HarnessType } from "@tangle-network/agent-interface";
|
|
2
|
+
/**
|
|
3
|
+
* The box: one image every cloud runs, holding Node, CLI Bridge and the pinned
|
|
4
|
+
* harness CLIs. The Bridge listens on {@link BRIDGE_PORT}, materializes the
|
|
5
|
+
* exact profile and starts the selected harness exactly as it does for Braid
|
|
6
|
+
* on a local machine.
|
|
7
|
+
*/
|
|
8
|
+
export declare const BRIDGE_PORT = 3344;
|
|
9
|
+
/** The box user's home. */
|
|
10
|
+
export declare const BOX_HOME = "/home/node";
|
|
11
|
+
/** The default workspace directory; `workspace.cwd` with `base: "repository"` resolves under it. */
|
|
12
|
+
export declare const BOX_WORKSPACE_DIR = "/home/node/workspace";
|
|
13
|
+
export declare const BRIDGE_DATA_DIR = "/home/node/.cli-bridge";
|
|
14
|
+
export declare const BOX_NODE_IMAGE = "node:24.22.1-trixie-slim";
|
|
15
|
+
/**
|
|
16
|
+
* The CLI Bridge source revision the box installs, reviewed as a commit.
|
|
17
|
+
* `agentProfileMaterialize` is the exact version of the planner that revision
|
|
18
|
+
* pins; {@link createBridgeSandboxProvider} plans with the same version, and
|
|
19
|
+
* the image build refuses a revision whose pin differs.
|
|
20
|
+
*/
|
|
21
|
+
export declare const BOX_PINS: {
|
|
22
|
+
readonly cliBridgeRepository: "drewstone/cli-bridge";
|
|
23
|
+
readonly cliBridgeCommit: "263f700dc5ca8b7b6e53b986d376102e0d82b42f";
|
|
24
|
+
readonly agentProfileMaterialize: "1.1.4";
|
|
25
|
+
};
|
|
26
|
+
export interface BoxRouter {
|
|
27
|
+
/** Router root URL, without a path. */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** The per-run capped Router key. */
|
|
30
|
+
key: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A harness the box carries and routes through the Tangle Router.
|
|
34
|
+
*
|
|
35
|
+
* A harness joins this table only after a live turn through the Router
|
|
36
|
+
* completed in a box, so the declared set is the proven set.
|
|
37
|
+
*/
|
|
38
|
+
export interface BoxHarness {
|
|
39
|
+
readonly harness: HarnessType;
|
|
40
|
+
/** The Bridge backend name in `BRIDGE_BACKENDS`. */
|
|
41
|
+
readonly bridgeBackend: string;
|
|
42
|
+
/** The npm package that installs the CLI. */
|
|
43
|
+
readonly npmPackage: string;
|
|
44
|
+
/** The CLI binary; `<bin> --version` prints the pinned version first. */
|
|
45
|
+
readonly bin: string;
|
|
46
|
+
/** Environment that points this harness at the Router, set on the Bridge process only. */
|
|
47
|
+
routerEnv(router: BoxRouter): Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
export declare const BOX_HARNESSES: Readonly<Partial<Record<HarnessType, BoxHarness>>>;
|
|
50
|
+
export declare function boxHarness(harness: string | undefined): BoxHarness | undefined;
|
|
51
|
+
/** The CLI version the harness registry pins; the box image installs exactly this. */
|
|
52
|
+
export declare function boxHarnessVersion(harness: HarnessType): string;
|
|
53
|
+
/** The command that starts the Bridge; its settings come from the process environment. */
|
|
54
|
+
export declare const BRIDGE_START_COMMAND = "cli-bridge";
|
|
55
|
+
/**
|
|
56
|
+
* The Bridge process environment. `bearer` guards the exposed port; the Router
|
|
57
|
+
* variables reach the harness through the Bridge's child environment and
|
|
58
|
+
* appear in no command line or file.
|
|
59
|
+
*/
|
|
60
|
+
export declare function bridgeProcessEnv(harness: BoxHarness, bearer: string, router: BoxRouter): Record<string, string>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { NATIVE_SESSION_CONTRACTS } from "@tangle-network/cli-agent-registry/native-session-contracts";
|
|
2
|
+
/**
|
|
3
|
+
* The box: one image every cloud runs, holding Node, CLI Bridge and the pinned
|
|
4
|
+
* harness CLIs. The Bridge listens on {@link BRIDGE_PORT}, materializes the
|
|
5
|
+
* exact profile and starts the selected harness exactly as it does for Braid
|
|
6
|
+
* on a local machine.
|
|
7
|
+
*/
|
|
8
|
+
export const BRIDGE_PORT = 3344;
|
|
9
|
+
/** The box user's home. */
|
|
10
|
+
export const BOX_HOME = "/home/node";
|
|
11
|
+
/** The default workspace directory; `workspace.cwd` with `base: "repository"` resolves under it. */
|
|
12
|
+
export const BOX_WORKSPACE_DIR = `${BOX_HOME}/workspace`;
|
|
13
|
+
export const BRIDGE_DATA_DIR = `${BOX_HOME}/.cli-bridge`;
|
|
14
|
+
export const BOX_NODE_IMAGE = "node:24.22.1-trixie-slim";
|
|
15
|
+
/**
|
|
16
|
+
* The CLI Bridge source revision the box installs, reviewed as a commit.
|
|
17
|
+
* `agentProfileMaterialize` is the exact version of the planner that revision
|
|
18
|
+
* pins; {@link createBridgeSandboxProvider} plans with the same version, and
|
|
19
|
+
* the image build refuses a revision whose pin differs.
|
|
20
|
+
*/
|
|
21
|
+
export const BOX_PINS = {
|
|
22
|
+
cliBridgeRepository: "drewstone/cli-bridge",
|
|
23
|
+
cliBridgeCommit: "263f700dc5ca8b7b6e53b986d376102e0d82b42f",
|
|
24
|
+
agentProfileMaterialize: "1.1.4",
|
|
25
|
+
};
|
|
26
|
+
export const BOX_HARNESSES = {
|
|
27
|
+
"claude-code": {
|
|
28
|
+
harness: "claude-code",
|
|
29
|
+
bridgeBackend: "claude",
|
|
30
|
+
npmPackage: "@anthropic-ai/claude-code",
|
|
31
|
+
bin: "claude",
|
|
32
|
+
// The Router authenticates a Bearer token. ANTHROPIC_API_KEY sends x-api-key, which it rejects.
|
|
33
|
+
routerEnv: (router) => ({
|
|
34
|
+
ANTHROPIC_BASE_URL: router.baseUrl,
|
|
35
|
+
ANTHROPIC_AUTH_TOKEN: router.key,
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export function boxHarness(harness) {
|
|
40
|
+
return harness === undefined ? undefined : BOX_HARNESSES[harness];
|
|
41
|
+
}
|
|
42
|
+
/** The CLI version the harness registry pins; the box image installs exactly this. */
|
|
43
|
+
export function boxHarnessVersion(harness) {
|
|
44
|
+
const contract = NATIVE_SESSION_CONTRACTS[harness];
|
|
45
|
+
if (contract?.cliVersion === undefined) {
|
|
46
|
+
throw new Error(`cli-agent-registry pins no CLI version for harness ${harness}`);
|
|
47
|
+
}
|
|
48
|
+
return contract.cliVersion;
|
|
49
|
+
}
|
|
50
|
+
/** The command that starts the Bridge; its settings come from the process environment. */
|
|
51
|
+
export const BRIDGE_START_COMMAND = "cli-bridge";
|
|
52
|
+
/**
|
|
53
|
+
* The Bridge process environment. `bearer` guards the exposed port; the Router
|
|
54
|
+
* variables reach the harness through the Bridge's child environment and
|
|
55
|
+
* appear in no command line or file.
|
|
56
|
+
*/
|
|
57
|
+
export function bridgeProcessEnv(harness, bearer, router) {
|
|
58
|
+
return {
|
|
59
|
+
BRIDGE_HOST: "0.0.0.0",
|
|
60
|
+
BRIDGE_PORT: String(BRIDGE_PORT),
|
|
61
|
+
BRIDGE_BEARER: bearer,
|
|
62
|
+
BRIDGE_BACKENDS: harness.bridgeBackend,
|
|
63
|
+
BRIDGE_DATA_DIR: BRIDGE_DATA_DIR,
|
|
64
|
+
BRIDGE_JAIL_MODE: "off",
|
|
65
|
+
HOME: BOX_HOME,
|
|
66
|
+
...harness.routerEnv(router),
|
|
67
|
+
};
|
|
68
|
+
}
|