@tangle-network/agent-provider-cli-bridge 0.6.4 → 0.7.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/README.md CHANGED
@@ -49,6 +49,9 @@ Passing the same `sessionId` on later turns continues the same CLI conversation.
49
49
  `dispatch()` starts a bridge-owned durable run and returns after detaching its HTTP reader.
50
50
  The returned `AgentSession` exposes status, cursor-based event replay, the terminal result, continuation, and cancellation.
51
51
  Cancellation returns only after cli-bridge confirms the run is terminal.
52
+ A direct stream treats a finish frame as provisional until cli-bridge returns the same run's aggregate result.
53
+ A retained replay confirms its terminal state from the retained run endpoint.
54
+ The provider rejects a terminal response when its run id or request digest does not match the accepted stream.
52
55
  Usage events and terminal result metadata include `modelRequests` only when the bridge measured an exact non-negative integer count.
53
56
  Missing, malformed, partial, or estimated request counts remain unknown.
54
57
  When `dispatch()` receives no `sessionId`, it creates one from that turn's stable run id and returns it.
@@ -58,3 +61,24 @@ Stopping a direct `environment.stream()` reader or destroying the environment ca
58
61
 
59
62
  Response headers and streamed bodies have no transport timeout by default.
60
63
  For unattended runs, set `headersTimeoutMs`, `bodyTimeoutMs`, or an `AbortSignal` so an unresponsive bridge cannot wait forever.
64
+
65
+ ## Environment observation
66
+
67
+ `environment.observe()` returns the normalized `AgentEnvironmentObservation` when the provider document carries an `observation` block.
68
+ Every surface keeps a freshness discriminator, so a value this bridge cannot report is visibly absent instead of arriving as a measured zero.
69
+
70
+ | Surface | cli-bridge source | State when the source is missing |
71
+ | --- | --- | --- |
72
+ | `identity` | provider name and environment id | always known |
73
+ | `lifecycle.status` | `running` until the environment is destroyed | always known |
74
+ | `endpoint` | scheme, host, and explicit port of `baseUrl` | `unavailable` |
75
+ | `placement.verified` | `defaultExecution.kind` as `sandbox` or `local` | always known |
76
+ | `modelUsage` | the token usage and cost the newest execution measured | `unavailable` |
77
+
78
+ cli-bridge forwards a turn and provisions no compute, so it states no requested or effective compute shape, no resource use, and no compute cost.
79
+ It holds no account, so it states no plan, credit balance, quota, or billing period.
80
+ Those five surfaces are never claimed in the capability document and always carry `unavailable` with the reason.
81
+
82
+ 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.
83
+ 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.
84
+ 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.
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { AgentEnvironmentCapabilities, AgentEnvironmentProvider } from "@ta
2
2
  import { type HarnessType } from "@tangle-network/agent-interface";
3
3
  import { type CliBridgeProviderOptions } from "./provider-options.js";
4
4
  export type { CliBridgeProviderOptions } from "./provider-options.js";
5
+ export { safeEndpointFromBaseUrl } from "./observation.js";
5
6
  export declare function createCliBridgeProvider(options: CliBridgeProviderOptions): AgentEnvironmentProvider;
6
7
  /**
7
8
  * Describe the bridge backend, not this adapter.
package/dist/index.js CHANGED
@@ -1,12 +1,26 @@
1
1
  import { harnessSystemPromptIntents, snapshotAgentProfile, } from "@tangle-network/agent-interface";
2
2
  import { createCliBridgeEnvironment } from "./retained-environment.js";
3
3
  import { assertCliBridgeProviderOptions, } from "./provider-options.js";
4
+ import { narrowedCliBridgeObservation } from "./observation.js";
5
+ export { safeEndpointFromBaseUrl } from "./observation.js";
4
6
  export function createCliBridgeProvider(options) {
5
7
  assertCliBridgeProviderOptions(options);
6
8
  const name = options.name ?? "cli-bridge";
9
+ // The observation surfaces are declared as intent and narrowed to the
10
+ // sources this bridge can put a value on, so the environment offers the
11
+ // operation exactly where the document claims it.
12
+ const resolveCapabilities = () => {
13
+ const declared = options.capabilities ?? defaultCliBridgeCapabilities();
14
+ return declared.observation === undefined
15
+ ? declared
16
+ : {
17
+ ...declared,
18
+ observation: narrowedCliBridgeObservation(declared.observation, options),
19
+ };
20
+ };
7
21
  return {
8
22
  name,
9
- capabilities: () => options.capabilities ?? defaultCliBridgeCapabilities(),
23
+ capabilities: resolveCapabilities,
10
24
  async create(input) {
11
25
  if (typeof input.profile === "string") {
12
26
  throw new Error(`createCliBridgeProvider requires an inline AgentProfile; named profile "${input.profile}" is unsupported`);
@@ -23,6 +37,7 @@ export function createCliBridgeProvider(options) {
23
37
  environmentId,
24
38
  allowDispatch: true,
25
39
  cancelRunsOnDestroy: true,
40
+ capabilities: resolveCapabilities(),
26
41
  });
27
42
  },
28
43
  async get(id) {
@@ -36,6 +51,7 @@ export function createCliBridgeProvider(options) {
36
51
  environmentId: id,
37
52
  allowDispatch: false,
38
53
  cancelRunsOnDestroy: false,
54
+ capabilities: resolveCapabilities(),
39
55
  });
40
56
  },
41
57
  };
@@ -88,5 +104,19 @@ export function defaultCliBridgeCapabilities(harness) {
88
104
  placement: true,
89
105
  usage: true,
90
106
  confidential: false,
107
+ // cli-bridge measures what a turn cost and where it forwarded that turn.
108
+ // It provisions no compute and holds no account, so the surfaces that
109
+ // describe provisioned resources and billing are never claimed.
110
+ observation: {
111
+ identity: true,
112
+ lifecycle: true,
113
+ endpoint: true,
114
+ placement: true,
115
+ resources: false,
116
+ resourceUse: false,
117
+ modelUsage: true,
118
+ computeBilling: false,
119
+ accountUsage: false,
120
+ },
91
121
  };
92
122
  }
@@ -0,0 +1,49 @@
1
+ import type { AgentEnvironmentCapabilities, AgentEnvironmentObservation, SafeEndpoint, TokenUsage } from "@tangle-network/agent-interface";
2
+ import type { CliBridgeProviderOptions } from "./provider-options.js";
3
+ /** One execution's accumulated token usage and cost. */
4
+ interface ExecutionUsageRecord {
5
+ executionId: string;
6
+ usage: TokenUsage;
7
+ observedAt: string;
8
+ }
9
+ /**
10
+ * The newest per-execution usage this environment handle measured.
11
+ *
12
+ * cli-bridge reports usage on the events of one run, so the totals are summed
13
+ * per execution and the newest execution answers the environment-scoped
14
+ * observation. The record lives with the handle: an environment rebuilt by id
15
+ * starts with none, and the observation then reports the usage surface as
16
+ * unavailable rather than as a measured zero.
17
+ */
18
+ export interface ExecutionUsageLog {
19
+ record(executionId: string | undefined, usage: TokenUsage | undefined): void;
20
+ latest(): ExecutionUsageRecord | undefined;
21
+ }
22
+ export declare function createExecutionUsageLog(): ExecutionUsageLog;
23
+ /**
24
+ * Read the credential-free network location of the cli-bridge endpoint.
25
+ *
26
+ * Only the scheme, host, and explicit port are carried. Userinfo, path, and
27
+ * query are dropped, and the bearer beside the base URL is never read, so an
28
+ * endpoint payload cannot transport a credential.
29
+ */
30
+ export declare function safeEndpointFromBaseUrl(baseUrl: string | undefined): SafeEndpoint | undefined;
31
+ /**
32
+ * Clear every observation surface no source backs.
33
+ *
34
+ * cli-bridge forwards a turn to a remote bridge and reports what that turn
35
+ * cost. It never provisions compute, so it states no compute shape, no
36
+ * resource use, no compute cost, and no account plan. The endpoint rests on a
37
+ * base URL that parses.
38
+ */
39
+ export declare function narrowedCliBridgeObservation(declared: NonNullable<AgentEnvironmentCapabilities["observation"]>, options: CliBridgeProviderOptions): NonNullable<AgentEnvironmentCapabilities["observation"]>;
40
+ export interface CliBridgeObservationSources {
41
+ options: CliBridgeProviderOptions;
42
+ provider: string;
43
+ environmentId: string;
44
+ destroyed: boolean;
45
+ usageLog: ExecutionUsageLog;
46
+ }
47
+ /** Build the normalized, freshness-tagged observation of one environment. */
48
+ export declare function observeCliBridgeEnvironment(sources: CliBridgeObservationSources): AgentEnvironmentObservation;
49
+ export {};
@@ -0,0 +1,141 @@
1
+ import { AgentEnvironmentObservationSchema } from "@tangle-network/agent-interface";
2
+ export function createExecutionUsageLog() {
3
+ let latest;
4
+ return {
5
+ record(executionId, usage) {
6
+ // Usage without the execution it belongs to cannot be attributed, and an
7
+ // execution without usage measured nothing. Neither is recorded.
8
+ if (executionId === undefined || executionId.length === 0)
9
+ return;
10
+ if (usage === undefined)
11
+ return;
12
+ latest = {
13
+ executionId,
14
+ usage,
15
+ observedAt: new Date().toISOString(),
16
+ };
17
+ },
18
+ latest() {
19
+ return latest === undefined
20
+ ? undefined
21
+ : { ...latest, usage: { ...latest.usage } };
22
+ },
23
+ };
24
+ }
25
+ /**
26
+ * Read the credential-free network location of the cli-bridge endpoint.
27
+ *
28
+ * Only the scheme, host, and explicit port are carried. Userinfo, path, and
29
+ * query are dropped, and the bearer beside the base URL is never read, so an
30
+ * endpoint payload cannot transport a credential.
31
+ */
32
+ export function safeEndpointFromBaseUrl(baseUrl) {
33
+ if (typeof baseUrl !== "string" || baseUrl.length === 0)
34
+ return undefined;
35
+ let parsed;
36
+ try {
37
+ parsed = new URL(baseUrl);
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ const scheme = parsed.protocol.replace(/:$/, "");
43
+ const host = parsed.hostname;
44
+ if (scheme.length === 0 || host.length === 0)
45
+ return undefined;
46
+ const port = parsed.port === "" ? undefined : Number.parseInt(parsed.port, 10);
47
+ if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) {
48
+ return undefined;
49
+ }
50
+ return { scheme, host, ...(port === undefined ? {} : { port }) };
51
+ }
52
+ /**
53
+ * Clear every observation surface no source backs.
54
+ *
55
+ * cli-bridge forwards a turn to a remote bridge and reports what that turn
56
+ * cost. It never provisions compute, so it states no compute shape, no
57
+ * resource use, no compute cost, and no account plan. The endpoint rests on a
58
+ * base URL that parses.
59
+ */
60
+ export function narrowedCliBridgeObservation(declared, options) {
61
+ const endpoint = safeEndpointFromBaseUrl(options.baseUrl) !== undefined;
62
+ return {
63
+ identity: declared.identity,
64
+ lifecycle: declared.lifecycle,
65
+ endpoint: endpoint ? declared.endpoint : false,
66
+ placement: declared.placement,
67
+ resources: false,
68
+ resourceUse: false,
69
+ modelUsage: declared.modelUsage,
70
+ computeBilling: false,
71
+ accountUsage: false,
72
+ };
73
+ }
74
+ /** Build the normalized, freshness-tagged observation of one environment. */
75
+ export function observeCliBridgeEnvironment(sources) {
76
+ const capturedAt = new Date().toISOString();
77
+ const provenance = {
78
+ origin: "reported",
79
+ observedAt: capturedAt,
80
+ source: "cli-bridge",
81
+ };
82
+ const subject = {
83
+ provider: sources.provider,
84
+ environmentId: sources.environmentId,
85
+ };
86
+ const endpoint = safeEndpointFromBaseUrl(sources.options.baseUrl);
87
+ const latest = sources.usageLog.latest();
88
+ const absent = (reason) => ({
89
+ state: "unavailable",
90
+ reason,
91
+ });
92
+ const noCompute = "cli-bridge forwards a turn and provisions no compute";
93
+ const noAccount = "cli-bridge reports no account plan, credit, or quota";
94
+ const noProbe = "cli-bridge never probes the bridge, so its runtime state is unproven";
95
+ return AgentEnvironmentObservationSchema.parse({
96
+ subject,
97
+ capturedAt,
98
+ identity: { state: "known", value: subject, provenance },
99
+ // A destroyed handle stopped because this adapter stopped it, which is the
100
+ // only lifecycle fact cli-bridge establishes. A live handle holds a base
101
+ // URL and nothing else, and configuration is not a running runtime.
102
+ lifecycle: sources.destroyed
103
+ ? { state: "known", value: { status: "stopped" }, provenance }
104
+ : absent(noProbe),
105
+ endpoint: endpoint === undefined
106
+ ? absent("the cli-bridge base URL states no reachable host")
107
+ : { state: "known", value: endpoint, provenance },
108
+ // The configured execution kind is where the caller asked the turn to run,
109
+ // so it is carried as the request. cli-bridge runs no placement check, so
110
+ // nothing verifies it.
111
+ placement: {
112
+ requested: {
113
+ kind: sources.options.defaultExecution?.kind === "sandbox" ? "sandbox" : "local",
114
+ },
115
+ verified: absent(noProbe),
116
+ },
117
+ resources: { effective: absent(noCompute) },
118
+ resourceUse: { current: absent(noCompute), peak: absent(noCompute) },
119
+ modelUsage: latest === undefined
120
+ ? absent("no execution measured token usage through this environment handle")
121
+ : {
122
+ state: "known",
123
+ value: latest.usage,
124
+ // The provenance source names the execution the usage belongs to,
125
+ // because cli-bridge measures usage per run and never for the
126
+ // environment.
127
+ provenance: {
128
+ origin: "reported",
129
+ observedAt: latest.observedAt,
130
+ source: latest.executionId,
131
+ },
132
+ },
133
+ computeBilling: absent(noCompute),
134
+ accountUsage: {
135
+ plan: absent(noAccount),
136
+ credits: absent(noAccount),
137
+ quota: absent(noAccount),
138
+ period: absent(noAccount),
139
+ },
140
+ });
141
+ }
@@ -1,4 +1,4 @@
1
- import type { AgentEnvironment, CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
1
+ import type { AgentEnvironment, AgentEnvironmentCapabilities, CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
2
2
  import type { CliBridgeProviderOptions } from "./provider-options.js";
3
3
  export interface CreateCliBridgeEnvironmentArgs {
4
4
  readonly options: CliBridgeProviderOptions;
@@ -7,5 +7,11 @@ export interface CreateCliBridgeEnvironmentArgs {
7
7
  readonly environmentId: string;
8
8
  readonly allowDispatch: boolean;
9
9
  readonly cancelRunsOnDestroy: boolean;
10
+ /**
11
+ * The document this provider publishes. The environment offers an optional
12
+ * operation only where the document claims it, so a caller never selects an
13
+ * action the document did not carry.
14
+ */
15
+ readonly capabilities: AgentEnvironmentCapabilities;
10
16
  }
11
17
  export declare function createCliBridgeEnvironment(args: CreateCliBridgeEnvironmentArgs): AgentEnvironment;
@@ -1,5 +1,6 @@
1
1
  import { agentSessionStatusFromRun, cancelCliBridgeRun, cancelExactCliBridgeRun, getCliBridgeRun, } from "./retained-control.js";
2
- import { collectCliBridgeTurnResult, dispatchCliBridgeTurn, streamCliBridgeSessionEvents, streamTrackedCliBridgeTurn, } from "./retained-execution.js";
2
+ import { addTokenUsage, collectCliBridgeTurnResult, dispatchCliBridgeTurn, streamCliBridgeSessionEvents, streamTrackedCliBridgeTurn, } from "./retained-execution.js";
3
+ import { createExecutionUsageLog, observeCliBridgeEnvironment, } from "./observation.js";
3
4
  import { assertRunMatchesControlRef, exactControlRefForSession, prepareCliBridgeRun, runFromControlRef, } from "./retained-run-state.js";
4
5
  import { createCliBridgeTransport } from "./transport.js";
5
6
  export function createCliBridgeEnvironment(args) {
@@ -8,6 +9,7 @@ export function createCliBridgeEnvironment(args) {
8
9
  const runs = new Map();
9
10
  const sessions = new Map();
10
11
  const readers = new Set();
12
+ const usageLog = createExecutionUsageLog();
11
13
  let destroyed = false;
12
14
  let closePromise;
13
15
  const stream = async function* (turn) {
@@ -17,7 +19,14 @@ export function createCliBridgeEnvironment(args) {
17
19
  throw new Error("a reconstructed cli-bridge environment can only control an existing run");
18
20
  }
19
21
  const prepared = prepareCliBridgeRun(options, environmentInput, turn, environmentId, false);
20
- yield* streamTrackedCliBridgeTurn(options, environmentInput, prepared, transport, runs, sessions, readers);
22
+ // A streamed run reports usage across its events, so the totals are summed
23
+ // here and recorded as one execution's measurement.
24
+ let streamedUsage;
25
+ for await (const event of streamTrackedCliBridgeTurn(options, environmentInput, prepared, transport, runs, sessions, readers)) {
26
+ streamedUsage = addTokenUsage(streamedUsage, event.usage);
27
+ usageLog.record(prepared.run.executionId, streamedUsage);
28
+ yield event;
29
+ }
21
30
  };
22
31
  return {
23
32
  id: environmentId,
@@ -52,8 +61,23 @@ export function createCliBridgeEnvironment(args) {
52
61
  allowPrompt: args.allowDispatch,
53
62
  requestedControlRef: sessionOptions?.controlRef,
54
63
  isDestroyed: () => destroyed,
64
+ usageLog,
55
65
  });
56
66
  },
67
+ ...(args.capabilities.observation
68
+ ? {
69
+ async observe(observeOptions) {
70
+ observeOptions?.signal?.throwIfAborted();
71
+ return observeCliBridgeEnvironment({
72
+ options,
73
+ provider: providerName,
74
+ environmentId,
75
+ destroyed,
76
+ usageLog,
77
+ });
78
+ },
79
+ }
80
+ : {}),
57
81
  placement: async (placementOptions) => {
58
82
  placementOptions?.signal?.throwIfAborted();
59
83
  return {
@@ -161,7 +185,9 @@ function createCliBridgeSession(args) {
161
185
  },
162
186
  async result(resultOptions) {
163
187
  const run = requireCurrentRun();
164
- return collectCliBridgeTurnResult(streamCliBridgeSessionEvents(args.options, args.environmentInput, run, args.transport, args.runs, args.readers, { since: "0", signal: resultOptions?.signal }), run, args.options, args.transport, resultOptions?.signal);
188
+ const result = await collectCliBridgeTurnResult(streamCliBridgeSessionEvents(args.options, args.environmentInput, run, args.transport, args.runs, args.readers, { since: "0", signal: resultOptions?.signal }), run, args.options, args.transport, resultOptions?.signal);
189
+ args.usageLog.record(run.executionId, result.usage);
190
+ return result;
165
191
  },
166
192
  async prompt(input) {
167
193
  if (!args.allowPrompt) {
@@ -175,6 +201,7 @@ function createCliBridgeSession(args) {
175
201
  sessionId: args.id,
176
202
  }, args.environmentId, false);
177
203
  const result = await collectCliBridgeTurnResult(streamTrackedCliBridgeTurn(args.options, args.environmentInput, prepared, args.transport, args.runs, args.sessions, args.readers), prepared.run, args.options, args.transport, input.signal);
204
+ args.usageLog.record(prepared.run.executionId, result.usage);
178
205
  return { ...result, sessionId: args.id };
179
206
  },
180
207
  async cancel(cancelOptions) {
@@ -1,3 +1,4 @@
1
+ import type { TokenUsage } from "@tangle-network/agent-interface";
1
2
  import type { AgentEnvironmentEvent, AgentSessionRef, AgentTurnResult, CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
2
3
  import type { CliBridgeProviderOptions } from "./provider-options.js";
3
4
  import { type CliBridgeRun, type CliBridgeSessionState, type PreparedCliBridgeRun } from "./retained-run-state.js";
@@ -10,3 +11,5 @@ export declare function streamCliBridgeSessionEvents(options: CliBridgeProviderO
10
11
  signal?: AbortSignal;
11
12
  }): AsyncIterable<AgentEnvironmentEvent>;
12
13
  export declare function collectCliBridgeTurnResult(source: AsyncIterable<AgentEnvironmentEvent>, run: CliBridgeRun, options: CliBridgeProviderOptions, transport: CliBridgeTransport, signal?: AbortSignal): Promise<AgentTurnResult>;
14
+ /** Sum the usage a run reported across its events into one total. */
15
+ export declare function addTokenUsage(current: TokenUsage | undefined, next: TokenUsage | undefined): TokenUsage | undefined;
@@ -27,7 +27,7 @@ export async function* streamTrackedCliBridgeTurn(options, environmentInput, pre
27
27
  let drained = false;
28
28
  let threw = false;
29
29
  try {
30
- for await (const event of streamCliBridgeTurn(options, turn, run.requestBody, transport, run.id, originalTurn.lastEventId, signal, (response) => captureCliBridgeRunIdentity(response, run, false))) {
30
+ for await (const event of streamCliBridgeTurn(options, turn, run.requestBody, transport, run.id, originalTurn.lastEventId, signal, (response) => captureCliBridgeRunIdentity(response, run, true), () => getCliBridgeRun(options, transport, run, 30_000, signal))) {
31
31
  yield event;
32
32
  }
33
33
  drained = true;
@@ -171,7 +171,7 @@ export async function* streamCliBridgeSessionEvents(options, environmentInput, r
171
171
  ...(run.sessionId ? { sessionId: run.sessionId } : {}),
172
172
  executionId: run.executionId,
173
173
  turnId: run.turnId,
174
- }, undefined, transport, run.id, eventOptions?.since ?? "0", signal, (response) => captureCliBridgeRunIdentity(response, run, false));
174
+ }, undefined, transport, run.id, eventOptions?.since ?? "0", signal, (response) => captureCliBridgeRunIdentity(response, run, false), () => getCliBridgeRun(options, transport, run, 30_000, signal));
175
175
  drained = true;
176
176
  if (runs.get(run.id) === run)
177
177
  runs.delete(run.id);
@@ -267,7 +267,8 @@ export async function collectCliBridgeTurnResult(source, run, options, transport
267
267
  events,
268
268
  };
269
269
  }
270
- function addTokenUsage(current, next) {
270
+ /** Sum the usage a run reported across its events into one total. */
271
+ export function addTokenUsage(current, next) {
271
272
  if (!next)
272
273
  return current;
273
274
  if (!current)
@@ -1,8 +1,9 @@
1
1
  import type { AgentEnvironmentEvent, AgentTurnInput } from "@tangle-network/agent-interface/environment-provider";
2
2
  import type { CliBridgeProviderOptions } from "./provider-options.js";
3
+ import type { CliBridgeRunSnapshot } from "./retained-run-state.js";
3
4
  import type { CliBridgeResponse, CliBridgeTransport } from "./transport.js";
4
5
  export declare class CliBridgeRequestRejectedError extends Error {
5
6
  readonly status: number;
6
7
  constructor(status: number, detail: string);
7
8
  }
8
- export declare function streamCliBridgeTurn(options: CliBridgeProviderOptions, turn: AgentTurnInput, requestBody: string | undefined, transport: CliBridgeTransport, runId: string, lastEventId?: string, signal?: AbortSignal, onAccepted?: (response: CliBridgeResponse) => void): AsyncIterable<AgentEnvironmentEvent>;
9
+ export declare function streamCliBridgeTurn(options: CliBridgeProviderOptions, turn: AgentTurnInput, requestBody: string | undefined, transport: CliBridgeTransport, runId: string, lastEventId?: string, signal?: AbortSignal, onResponse?: (response: CliBridgeResponse) => void, readAuthoritativeRun?: () => Promise<CliBridgeRunSnapshot | null>): AsyncIterable<AgentEnvironmentEvent>;
@@ -1,4 +1,5 @@
1
1
  import { retainedEventsWithIdentity, retainedReplayRequest, } from "./retained-event-cursor.js";
2
+ import { detachCliBridgeReader } from "./retained-control.js";
2
3
  import { requestHeaders, trimSlash } from "./transport.js";
3
4
  import { modelRequestsFromOpenAi, parseSse, responseIdentityFromOpenAi, safeJson, toolCallsFromDelta, usageFromOpenAi, } from "./wire.js";
4
5
  export class CliBridgeRequestRejectedError extends Error {
@@ -9,7 +10,15 @@ export class CliBridgeRequestRejectedError extends Error {
9
10
  this.name = "CliBridgeRequestRejectedError";
10
11
  }
11
12
  }
12
- export async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onAccepted) {
13
+ class CliBridgeRunCancelledError extends Error {
14
+ detail;
15
+ constructor(detail) {
16
+ super(`cli-bridge run cancelled: ${detail}`);
17
+ this.detail = detail;
18
+ this.name = "CliBridgeRunCancelledError";
19
+ }
20
+ }
21
+ export async function* streamCliBridgeTurn(options, turn, requestBody, transport, runId, lastEventId, signal, onResponse, readAuthoritativeRun) {
13
22
  const reconnect = requestBody === undefined;
14
23
  const replay = retainedReplayRequest(lastEventId);
15
24
  const response = await transport.fetch(reconnect
@@ -37,9 +46,20 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
37
46
  }
38
47
  throw new CliBridgeRequestRejectedError(response.status, detail);
39
48
  }
40
- onAccepted?.(response);
41
49
  if (!response.body)
42
50
  throw new Error("cli-bridge response body is empty");
51
+ try {
52
+ onResponse?.(response);
53
+ }
54
+ catch (error) {
55
+ try {
56
+ await detachCliBridgeReader(response.body);
57
+ }
58
+ catch (detachError) {
59
+ throw new AggregateError([error, detachError], `cli-bridge rejected response identity for run "${runId}" and could not detach its reader`);
60
+ }
61
+ throw error;
62
+ }
43
63
  let text = "";
44
64
  const sessionId = turn.sessionId ?? runId;
45
65
  const messageId = turn.turnId ?? `${sessionId}:assistant`;
@@ -51,25 +71,39 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
51
71
  let servedModel;
52
72
  let systemFingerprint;
53
73
  let textBoundaryPending = false;
54
- let terminalCursor;
55
74
  let sawProtocolEnd = false;
75
+ let terminalFrameSeen = false;
76
+ let cancelledTerminalSeen = false;
77
+ let terminalFrame;
56
78
  for await (const frame of parseSse(response.body)) {
57
79
  if (frame.data === "[DONE]") {
58
80
  sawProtocolEnd = true;
59
81
  continue;
60
82
  }
83
+ if (terminalFrameSeen)
84
+ continue;
61
85
  const parsed = safeJson(frame.data);
62
86
  if (!parsed)
63
87
  continue;
64
88
  if (parsed.error && typeof parsed.error === "object") {
65
89
  const error = parsed.error;
66
90
  const message = typeof error.message === "string" ? error.message : "cli-bridge error";
91
+ const cancelled = error.type === "run_cancelled";
67
92
  yield* retainedEventsWithIdentity([{
68
93
  type: "status",
69
- data: { status: "failed", error: message },
70
- normalized: { type: "status", status: "failed", detail: message },
94
+ data: { status: cancelled ? "cancelled" : "failed", error: message },
95
+ normalized: {
96
+ type: "status",
97
+ status: cancelled ? "cancelled" : "failed",
98
+ detail: message,
99
+ },
71
100
  }], frame.id, runId, sessionId, turn.executionId, replay.anchor);
72
- throw new Error(`cli-bridge: ${message}`);
101
+ terminalFrameSeen = true;
102
+ completed = cancelled;
103
+ cancelledTerminalSeen = cancelled;
104
+ if (!cancelled)
105
+ throw new Error(`cli-bridge: ${message}`);
106
+ continue;
73
107
  }
74
108
  const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : undefined;
75
109
  const delta = choice?.delta;
@@ -161,6 +195,7 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
161
195
  if (toolCallDeltas.length > 0)
162
196
  textBoundaryPending = true;
163
197
  if (choice?.finish_reason) {
198
+ terminalFrameSeen = true;
164
199
  if (choice.finish_reason === "error") {
165
200
  frameEvents.push({
166
201
  type: "status",
@@ -175,61 +210,160 @@ export async function* streamCliBridgeTurn(options, turn, requestBody, transport
175
210
  throw new Error("cli-bridge returned finish_reason=error");
176
211
  }
177
212
  completed = true;
178
- if (lastEventId && requestBody !== undefined) {
179
- terminalCursor = frame.id === undefined
180
- ? undefined
181
- : `${frame.id}:${frameEvents.length}`;
182
- }
183
- else {
184
- frameEvents.push({
185
- type: "result",
186
- data: {
187
- finalText: text,
188
- finishReason: choice.finish_reason,
189
- status: "completed",
190
- ...identityData,
191
- ...(modelRequestsKnown ? { modelRequests: observedModelRequests } : {}),
192
- },
193
- });
194
- }
213
+ terminalFrame = {
214
+ ...(frame.id === undefined ? {} : { id: frame.id }),
215
+ events: frameEvents,
216
+ finishReason: choice.finish_reason,
217
+ };
218
+ continue;
195
219
  }
196
220
  yield* retainedEventsWithIdentity(frameEvents, frame.id, runId, sessionId, turn.executionId, replay.anchor);
197
221
  }
198
222
  if (!sawProtocolEnd) {
199
223
  throw new Error("cli-bridge stream ended without the [DONE] protocol marker");
200
224
  }
225
+ if (cancelledTerminalSeen)
226
+ return;
201
227
  if (!completed && lastEventId === undefined) {
228
+ let snapshot;
229
+ try {
230
+ snapshot = await terminalRunSnapshot(runId, readAuthoritativeRun);
231
+ }
232
+ catch (error) {
233
+ throw new Error("cli-bridge stream ended without a terminal result", {
234
+ cause: error,
235
+ });
236
+ }
237
+ if (snapshot.status === "cancelled") {
238
+ const event = cancelledEvent(`cli-bridge run "${runId}" was cancelled`);
239
+ yield {
240
+ ...event,
241
+ data: {
242
+ ...event.data,
243
+ runId,
244
+ sessionId,
245
+ ...(turn.executionId === undefined
246
+ ? {}
247
+ : { executionId: turn.executionId }),
248
+ },
249
+ };
250
+ return;
251
+ }
202
252
  throw new Error("cli-bridge stream ended without a terminal result");
203
253
  }
204
- if (lastEventId && requestBody !== undefined) {
205
- const result = await readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted);
206
- const finalModelRequests = result.modelRequests ??
207
- (modelRequestsKnown ? observedModelRequests : undefined);
208
- const finalCursor = terminalCursor ?? lastEventId;
209
- yield {
210
- type: "result",
211
- data: {
212
- finalText: result.text,
213
- finishReason: result.finishReason,
214
- status: "completed",
215
- ...(result.model === undefined ? {} : { model: result.model }),
216
- ...(result.system_fingerprint === undefined
217
- ? {}
218
- : { system_fingerprint: result.system_fingerprint }),
219
- runId,
220
- sessionId,
221
- ...(turn.executionId === undefined ? {} : { executionId: turn.executionId }),
222
- ...(finalCursor === undefined ? {} : { cursor: finalCursor }),
223
- ...(finalModelRequests === undefined
224
- ? {}
225
- : { modelRequests: finalModelRequests }),
226
- },
227
- ...(result.usage && !sawUsage ? { usage: result.usage } : {}),
228
- ...(finalCursor === undefined ? {} : { id: finalCursor }),
229
- };
254
+ if (!terminalFrame) {
255
+ if (requestBody !== undefined && lastEventId !== undefined) {
256
+ try {
257
+ await readFullCliBridgeResult(options, requestBody, transport, signal, onResponse);
258
+ }
259
+ catch (error) {
260
+ if (error instanceof CliBridgeRunCancelledError)
261
+ return;
262
+ throw error;
263
+ }
264
+ }
265
+ else if (requestBody === undefined) {
266
+ await terminalRunSnapshot(runId, readAuthoritativeRun);
267
+ }
268
+ return;
230
269
  }
270
+ let terminalEvent;
271
+ let terminalFailure;
272
+ if (requestBody !== undefined) {
273
+ let result;
274
+ try {
275
+ result = await readFullCliBridgeResult(options, requestBody, transport, signal, onResponse);
276
+ }
277
+ catch (error) {
278
+ if (error instanceof CliBridgeRunCancelledError) {
279
+ terminalEvent = cancelledEvent(error.detail);
280
+ }
281
+ else {
282
+ throw error;
283
+ }
284
+ }
285
+ if (result !== undefined) {
286
+ const finalModelRequests = result.modelRequests ??
287
+ (modelRequestsKnown ? observedModelRequests : undefined);
288
+ terminalEvent = {
289
+ type: "result",
290
+ data: {
291
+ finalText: lastEventId === undefined ? text : result.text,
292
+ finishReason: lastEventId === undefined ? terminalFrame.finishReason : result.finishReason,
293
+ status: "completed",
294
+ ...(result.model === undefined ? {} : { model: result.model }),
295
+ ...(result.system_fingerprint === undefined
296
+ ? {}
297
+ : { system_fingerprint: result.system_fingerprint }),
298
+ ...(finalModelRequests === undefined
299
+ ? {}
300
+ : { modelRequests: finalModelRequests }),
301
+ },
302
+ ...(result.usage && !sawUsage ? { usage: result.usage } : {}),
303
+ };
304
+ }
305
+ }
306
+ else {
307
+ const snapshot = await terminalRunSnapshot(runId, readAuthoritativeRun);
308
+ if (snapshot.status === "cancelled") {
309
+ terminalEvent = cancelledEvent(`cli-bridge run "${runId}" was cancelled`);
310
+ }
311
+ else if (snapshot.status === "error") {
312
+ const detail = `cli-bridge run "${runId}" ended with an error`;
313
+ terminalEvent = failedEvent(detail);
314
+ terminalFailure = new Error(detail);
315
+ }
316
+ else {
317
+ terminalEvent = {
318
+ type: "result",
319
+ data: {
320
+ finalText: text,
321
+ finishReason: terminalFrame.finishReason,
322
+ status: "completed",
323
+ ...(servedModel === undefined ? {} : { model: servedModel }),
324
+ ...(systemFingerprint === undefined
325
+ ? {}
326
+ : { system_fingerprint: systemFingerprint }),
327
+ ...(modelRequestsKnown ? { modelRequests: observedModelRequests } : {}),
328
+ },
329
+ };
330
+ }
331
+ }
332
+ if (!terminalEvent) {
333
+ throw new Error("cli-bridge terminal state did not produce a terminal event");
334
+ }
335
+ terminalFrame.events.push(terminalEvent);
336
+ yield* retainedEventsWithIdentity(terminalFrame.events, terminalFrame.id, runId, sessionId, turn.executionId, replay.anchor);
337
+ if (terminalFailure)
338
+ throw terminalFailure;
339
+ }
340
+ async function terminalRunSnapshot(runId, readAuthoritativeRun) {
341
+ if (!readAuthoritativeRun) {
342
+ throw new Error("cli-bridge retained stream omitted authoritative run status");
343
+ }
344
+ const snapshot = await readAuthoritativeRun();
345
+ if (!snapshot)
346
+ throw new Error(`cli-bridge lost run "${runId}" before terminal replay`);
347
+ if (!snapshot.terminal || snapshot.status === "running") {
348
+ throw new Error(`cli-bridge run "${runId}" remained active after its stream ended`);
349
+ }
350
+ return snapshot;
351
+ }
352
+ function cancelledEvent(detail) {
353
+ return {
354
+ type: "status",
355
+ data: { status: "cancelled", error: detail },
356
+ normalized: { type: "status", status: "cancelled", detail },
357
+ };
231
358
  }
232
- async function readFullCliBridgeResult(options, requestBody, transport, signal, onAccepted) {
359
+ function failedEvent(detail) {
360
+ return {
361
+ type: "status",
362
+ data: { status: "failed", error: detail },
363
+ normalized: { type: "status", status: "failed", detail },
364
+ };
365
+ }
366
+ async function readFullCliBridgeResult(options, requestBody, transport, signal, onResponse) {
233
367
  const body = safeJson(requestBody);
234
368
  if (!body)
235
369
  throw new Error("cli-bridge replay request is not valid JSON");
@@ -239,15 +373,22 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal,
239
373
  body: JSON.stringify({ ...body, stream: false }),
240
374
  ...(signal ? { signal } : {}),
241
375
  });
376
+ const responseText = await response.text();
377
+ onResponse?.(response);
242
378
  if (!response.ok) {
243
- throw new Error(`cli-bridge replay result ${response.status}: ${await response.text()}`);
379
+ const error = cliBridgeError(safeJson(responseText));
380
+ if (error?.type === "run_cancelled") {
381
+ throw new CliBridgeRunCancelledError(cliBridgeErrorMessage(error));
382
+ }
383
+ throw new Error(`cli-bridge replay result ${response.status}: ${responseText}`);
244
384
  }
245
- onAccepted?.(response);
246
- const parsed = safeJson(await response.text());
247
- if (parsed?.error && typeof parsed.error === "object") {
248
- const error = parsed.error;
249
- const message = typeof error.message === "string" ? error.message : "cli-bridge replay failed";
250
- throw new Error(`cli-bridge replay result failed: ${message}`);
385
+ const parsed = safeJson(responseText);
386
+ const error = cliBridgeError(parsed);
387
+ if (error) {
388
+ if (error.type === "run_cancelled") {
389
+ throw new CliBridgeRunCancelledError(cliBridgeErrorMessage(error));
390
+ }
391
+ throw new Error(`cli-bridge replay result failed: ${cliBridgeErrorMessage(error)}`);
251
392
  }
252
393
  const choice = Array.isArray(parsed?.choices) ? parsed.choices[0] : undefined;
253
394
  const message = choice?.message && typeof choice.message === "object"
@@ -274,3 +415,12 @@ async function readFullCliBridgeResult(options, requestBody, transport, signal,
274
415
  : { system_fingerprint: responseIdentity.system_fingerprint }),
275
416
  };
276
417
  }
418
+ function cliBridgeError(parsed) {
419
+ const error = parsed?.error;
420
+ return error && typeof error === "object" && !Array.isArray(error)
421
+ ? error
422
+ : undefined;
423
+ }
424
+ function cliBridgeErrorMessage(error) {
425
+ return typeof error.message === "string" ? error.message : "cli-bridge replay failed";
426
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-cli-bridge",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "description": "AgentEnvironmentProvider adapter for cli-bridge /v1/chat/completions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,8 @@
25
25
  "files": [
26
26
  "dist/index.d.ts",
27
27
  "dist/index.js",
28
+ "dist/observation.d.ts",
29
+ "dist/observation.js",
28
30
  "dist/provider-options.d.ts",
29
31
  "dist/provider-options.js",
30
32
  "dist/retained-control.d.ts",
@@ -47,7 +49,7 @@
47
49
  "LICENSE"
48
50
  ],
49
51
  "dependencies": {
50
- "@tangle-network/agent-interface": "0.51.0",
52
+ "@tangle-network/agent-interface": "0.52.0",
51
53
  "undici": "7.29.0"
52
54
  },
53
55
  "devDependencies": {