@tangle-network/agent-provider-cli-bridge 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,6 +64,42 @@ Replay and result reads fail loudly after cli-bridge's configured replay retenti
64
64
  Stopping a `session.events()` reader detaches only that replay observer.
65
65
  Stopping a direct `environment.stream()` reader or destroying the environment cancels its active bridge runs and waits for terminal confirmation.
66
66
 
67
+ Pi retained sessions also expose typed permission interactions.
68
+ The provider stores the selected harness, exact model route, and canonical create digest inside its opaque environment identifier.
69
+ This identifier lets `provider.get()` reconstruct profile-selected routes after process death without a cache or repeated configuration.
70
+ The create digest prevents an altered profile or workspace from reusing the previous retained identity.
71
+ `provider.get()` accepts only identifiers created by this provider and rejects plain caller identifiers.
72
+ The provider queries `/v1/capabilities` for the retained Pi route.
73
+ The provider caches the valid response and enables native interactions only when the running Bridge proves the complete retained contract.
74
+ Every native turn stays on the model route used for that discovery; another model requires another environment.
75
+ An explicit capability document remains available for a caller that already performed the same discovery.
76
+ That capability selects cli-bridge's native `/v1/sessions` and `/turns` transport; it never sends an interactive turn to `/v1/chat/completions`.
77
+ The environment and session then expose `respondToInteraction()` for exact response commands.
78
+ Each command carries its run, session, execution, request digest, and stable operation identifier.
79
+ Native turns require caller-stable `turnId` and `executionId` values so an ambiguous admission cannot create another run.
80
+ The bridge records repeated operations and rejects a different answer for an existing operation.
81
+ The provider reports an unconfirmed network result as retryable and never reports it as accepted.
82
+ Native replay reads `/v1/runs/:runId/events` and validates each canonical envelope through Agent Interface before exposing it.
83
+ When a turn supplies `interactions`, the provider carries its exact map, including an explicit `{}`; an omitted map remains omitted.
84
+ Every requested key must be advertised by the selected environment, including keys set to `false`; an unsupported key fails before transport use.
85
+ One-shot requests keep this posture as a top-level `interactions` field rather than moving it into `metadata`.
86
+ The cli-bridge turn schema must accept that top-level field and include it in its request digest.
87
+ Other runners stay disabled until cli-bridge proves the same replay and response behavior for them.
88
+
89
+ To run the real cross-repository contract, point the test at an installed cli-bridge source checkout:
90
+
91
+ ```sh
92
+ CLI_BRIDGE_INTEGRATION_ROOT=/path/to/cli-bridge pnpm test -- tests/cli-bridge.integration.test.ts
93
+ ```
94
+
95
+ Use `pnpm test:integration` for the fail-closed form of this command.
96
+ It exits before Vitest when `CLI_BRIDGE_INTEGRATION_ROOT` is missing.
97
+ The regular `pnpm test` command remains portable and skips this cross-repository test when no checkout is configured.
98
+
99
+ The test launches the actual Bridge server and a jailed Pi RPC process.
100
+ It proves dispatch, permission response, completion, reconnect, replay, and idempotent response retry.
101
+ CI runs this test in a separate job against a pinned Bridge checkout, rebuilds its native SQLite binding, and fails if the checkout cannot build or the integration test is skipped.
102
+
67
103
  Response headers and streamed bodies have no transport timeout by default.
68
104
  For unattended runs, set `headersTimeoutMs`, `bodyTimeoutMs`, or an `AbortSignal` so an unresponsive bridge cannot wait forever.
69
105
 
@@ -0,0 +1,15 @@
1
+ import { type AgentEnvironmentCapabilities } from "@tangle-network/agent-interface/environment-provider";
2
+ import type { CliBridgeProviderOptions } from "./provider-options.js";
3
+ /** Read the retained contract from the exact Bridge model route. */
4
+ export declare function discoverCliBridgeCapabilities(options: CliBridgeProviderOptions, model: string): Promise<AgentEnvironmentCapabilities>;
5
+ /** Share one readiness request between capability checks and environment creation. */
6
+ export declare function cachedCliBridgeCapabilityDiscovery(options: CliBridgeProviderOptions): (model: string) => Promise<AgentEnvironmentCapabilities>;
7
+ /**
8
+ * Publish only operations implemented by this adapter and proved by Bridge.
9
+ *
10
+ * Bridge owns backend truth. This provider owns its HTTP adapter and local
11
+ * observation surfaces. A remote capability cannot create a provider method.
12
+ */
13
+ export declare function narrowCliBridgeCapabilities(adapter: AgentEnvironmentCapabilities, reported: AgentEnvironmentCapabilities, selectedBackend?: string, options?: {
14
+ preserveAdapterObservation?: boolean;
15
+ }): AgentEnvironmentCapabilities;
@@ -0,0 +1,164 @@
1
+ import { AgentEnvironmentCapabilitiesSchema, } from "@tangle-network/agent-interface/environment-provider";
2
+ import { supportsCliBridgeNativeInteractions } from "./retained-native.js";
3
+ import { createCliBridgeTransport, requestHeaders, trimSlash, } from "./transport.js";
4
+ /** Read the retained contract from the exact Bridge model route. */
5
+ export async function discoverCliBridgeCapabilities(options, model) {
6
+ const transport = createCliBridgeTransport(options);
7
+ try {
8
+ const response = await transport.fetch(`${trimSlash(options.baseUrl)}/v1/capabilities?model=${encodeURIComponent(model)}`, {
9
+ method: "GET",
10
+ headers: requestHeaders(options),
11
+ });
12
+ if (!response.ok) {
13
+ throw new Error(`cli-bridge capability discovery returned HTTP ${response.status}: ${await response.text()}`);
14
+ }
15
+ let value;
16
+ try {
17
+ value = JSON.parse(await response.text());
18
+ }
19
+ catch (error) {
20
+ throw new Error("cli-bridge capability discovery returned invalid JSON", { cause: error });
21
+ }
22
+ const parsed = AgentEnvironmentCapabilitiesSchema.safeParse(value);
23
+ if (!parsed.success) {
24
+ throw new Error("cli-bridge capability discovery returned an invalid capability document", {
25
+ cause: parsed.error,
26
+ });
27
+ }
28
+ return parsed.data;
29
+ }
30
+ finally {
31
+ await transport.close();
32
+ }
33
+ }
34
+ /** Share one readiness request between capability checks and environment creation. */
35
+ export function cachedCliBridgeCapabilityDiscovery(options) {
36
+ const cache = new Map();
37
+ return (model) => {
38
+ const existing = cache.get(model);
39
+ if (existing)
40
+ return existing;
41
+ const pending = discoverCliBridgeCapabilities(options, model);
42
+ cache.set(model, pending);
43
+ void pending.catch(() => {
44
+ if (cache.get(model) === pending)
45
+ cache.delete(model);
46
+ });
47
+ return pending;
48
+ };
49
+ }
50
+ /**
51
+ * Publish only operations implemented by this adapter and proved by Bridge.
52
+ *
53
+ * Bridge owns backend truth. This provider owns its HTTP adapter and local
54
+ * observation surfaces. A remote capability cannot create a provider method.
55
+ */
56
+ export function narrowCliBridgeCapabilities(adapter, reported, selectedBackend, options = {}) {
57
+ const streaming = {
58
+ live: adapter.streaming.live && reported.streaming.live,
59
+ replay: adapter.streaming.replay && reported.streaming.replay,
60
+ detach: adapter.streaming.detach && reported.streaming.detach,
61
+ turnIdempotency: adapter.streaming.turnIdempotency && reported.streaming.turnIdempotency,
62
+ };
63
+ const sessions = {
64
+ continue: adapter.sessions.continue && reported.sessions.continue,
65
+ // The Bridge can list and inspect sessions, but this adapter exposes neither method.
66
+ list: false,
67
+ messages: false,
68
+ };
69
+ const retainedControl = supportsRetainedControl(adapter) && supportsRetainedControl(reported)
70
+ ? {
71
+ exactRunIdentity: true,
72
+ resultIdentity: true,
73
+ eventIdentity: true,
74
+ cancellationIdempotency: true,
75
+ }
76
+ : undefined;
77
+ const interactions = supportsCliBridgeNativeInteractions(adapter, selectedBackend) &&
78
+ supportsCliBridgeNativeInteractions(reported, selectedBackend)
79
+ ? adapter.interactions
80
+ : undefined;
81
+ const observation = options.preserveAdapterObservation
82
+ ? adapter.observation
83
+ : intersectObservation(adapter.observation, reported.observation);
84
+ const candidate = {
85
+ profile: {
86
+ namedProfiles: adapter.profile.namedProfiles && reported.profile.namedProfiles,
87
+ systemPrompt: {
88
+ replace: adapter.profile.systemPrompt.replace && reported.profile.systemPrompt.replace,
89
+ append: adapter.profile.systemPrompt.append && reported.profile.systemPrompt.append,
90
+ },
91
+ instructions: adapter.profile.instructions && reported.profile.instructions,
92
+ tools: adapter.profile.tools && reported.profile.tools,
93
+ permissions: adapter.profile.permissions && reported.profile.permissions,
94
+ mcp: adapter.profile.mcp && reported.profile.mcp,
95
+ subagents: adapter.profile.subagents && reported.profile.subagents,
96
+ resources: {
97
+ files: adapter.profile.resources.files && reported.profile.resources.files,
98
+ instructions: adapter.profile.resources.instructions && reported.profile.resources.instructions,
99
+ tools: adapter.profile.resources.tools === true && reported.profile.resources.tools === true,
100
+ skills: adapter.profile.resources.skills === true && reported.profile.resources.skills === true,
101
+ agents: adapter.profile.resources.agents === true && reported.profile.resources.agents === true,
102
+ commands: adapter.profile.resources.commands === true && reported.profile.resources.commands === true,
103
+ },
104
+ hooks: adapter.profile.hooks === true && reported.profile.hooks === true,
105
+ modes: adapter.profile.modes === true && reported.profile.modes === true,
106
+ runtimeUpdate: adapter.profile.runtimeUpdate && reported.profile.runtimeUpdate,
107
+ // AgentEnvironmentProvider has no profile validator, even when Bridge does.
108
+ validation: false,
109
+ ...intersectExtensions(adapter.profile.extensions, reported.profile.extensions),
110
+ },
111
+ streaming,
112
+ sessions,
113
+ ...(retainedControl === undefined ? {} : { retainedControl }),
114
+ ...(interactions === undefined ? {} : { interactions }),
115
+ // Remote workspace support cannot create absent adapter methods.
116
+ workspace: {
117
+ read: false,
118
+ write: false,
119
+ exec: false,
120
+ git: false,
121
+ upload: false,
122
+ download: false,
123
+ },
124
+ branching: { checkpoint: false, fork: false },
125
+ placement: adapter.placement && reported.placement,
126
+ usage: adapter.usage && reported.usage,
127
+ confidential: adapter.confidential && reported.confidential,
128
+ // Observation is measured by this adapter, not by the remote backend document.
129
+ ...(observation === undefined ? {} : { observation }),
130
+ };
131
+ return AgentEnvironmentCapabilitiesSchema.parse(candidate);
132
+ }
133
+ function intersectObservation(adapter, reported) {
134
+ if (adapter === undefined || reported === undefined)
135
+ return undefined;
136
+ return {
137
+ identity: adapter.identity && reported.identity,
138
+ lifecycle: adapter.lifecycle && reported.lifecycle,
139
+ endpoint: adapter.endpoint && reported.endpoint,
140
+ placement: adapter.placement && reported.placement,
141
+ resources: adapter.resources && reported.resources,
142
+ resourceUse: adapter.resourceUse && reported.resourceUse,
143
+ modelUsage: adapter.modelUsage && reported.modelUsage,
144
+ computeBilling: adapter.computeBilling && reported.computeBilling,
145
+ accountUsage: adapter.accountUsage && reported.accountUsage,
146
+ };
147
+ }
148
+ function supportsRetainedControl(capabilities) {
149
+ const retained = capabilities.retainedControl;
150
+ return Boolean(retained?.exactRunIdentity &&
151
+ retained.resultIdentity &&
152
+ retained.eventIdentity &&
153
+ retained.cancellationIdempotency &&
154
+ capabilities.streaming.replay &&
155
+ capabilities.streaming.detach &&
156
+ capabilities.streaming.turnIdempotency &&
157
+ capabilities.sessions.continue);
158
+ }
159
+ function intersectExtensions(adapter, reported) {
160
+ if (adapter === undefined || reported === undefined)
161
+ return {};
162
+ const allowed = new Set(reported);
163
+ return { extensions: adapter.filter((extension) => allowed.has(extension)) };
164
+ }
@@ -0,0 +1,13 @@
1
+ import type { Sha256Digest } from "@tangle-network/agent-interface";
2
+ export interface CliBridgeEnvironmentRoute {
3
+ readonly backend?: string;
4
+ readonly model?: string;
5
+ readonly createDigest: Sha256Digest;
6
+ }
7
+ /**
8
+ * Keep the exact route and create digest inside the provider-owned id.
9
+ * The nonce hides a caller idempotency key while repeated creates stay stable.
10
+ */
11
+ export declare function cliBridgeEnvironmentId(route: Omit<CliBridgeEnvironmentRoute, "createDigest">, createDigest: Sha256Digest, idempotencyKey?: string): string;
12
+ /** Recover the exact route from a provider-owned retained identifier. */
13
+ export declare function cliBridgeEnvironmentRoute(id: string): CliBridgeEnvironmentRoute;
@@ -0,0 +1,87 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ const CLI_BRIDGE_ENVIRONMENT_ID_PREFIX = "cb1.";
3
+ const MAX_ENVIRONMENT_ID_LENGTH = 512;
4
+ /**
5
+ * Keep the exact route and create digest inside the provider-owned id.
6
+ * The nonce hides a caller idempotency key while repeated creates stay stable.
7
+ */
8
+ export function cliBridgeEnvironmentId(route, createDigest, idempotencyKey) {
9
+ assertEnvironmentRoute(route.backend, route.model, createDigest);
10
+ const nonce = idempotencyKey === undefined
11
+ ? randomUUID()
12
+ : createHash("sha256").update(idempotencyKey).digest("base64url");
13
+ const payload = JSON.stringify([
14
+ route.backend ?? null,
15
+ route.model ?? null,
16
+ createDigest,
17
+ nonce,
18
+ ]);
19
+ const id = `${CLI_BRIDGE_ENVIRONMENT_ID_PREFIX}${Buffer.from(payload).toString("base64url")}`;
20
+ if (id.length > MAX_ENVIRONMENT_ID_LENGTH) {
21
+ throw new Error("cli-bridge route is too long to retain in an environment id");
22
+ }
23
+ return id;
24
+ }
25
+ /** Recover the exact route from a provider-owned retained identifier. */
26
+ export function cliBridgeEnvironmentRoute(id) {
27
+ if (!id.startsWith(CLI_BRIDGE_ENVIRONMENT_ID_PREFIX)) {
28
+ throw new Error("cli-bridge environment id is not a provider-owned retained identity");
29
+ }
30
+ if (id.length > MAX_ENVIRONMENT_ID_LENGTH) {
31
+ throw new Error("cli-bridge environment id has invalid retained route data");
32
+ }
33
+ const encoded = id.slice(CLI_BRIDGE_ENVIRONMENT_ID_PREFIX.length);
34
+ let payload;
35
+ try {
36
+ const decoded = Buffer.from(encoded, "base64url");
37
+ if (decoded.toString("base64url") !== encoded)
38
+ throw new Error("non-canonical encoding");
39
+ payload = JSON.parse(decoded.toString("utf8"));
40
+ }
41
+ catch {
42
+ throw new Error("cli-bridge environment id has invalid retained route data");
43
+ }
44
+ if (!Array.isArray(payload) || payload.length !== 4) {
45
+ throw new Error("cli-bridge environment id has invalid retained route data");
46
+ }
47
+ const [backend, model, createDigest, nonce] = payload;
48
+ if (!optionalRouteValue(backend) ||
49
+ !optionalRouteValue(model) ||
50
+ !sha256Digest(createDigest) ||
51
+ !modelMatchesBackend(backend, model) ||
52
+ typeof nonce !== "string" ||
53
+ nonce.length === 0 ||
54
+ nonce.length > 64) {
55
+ throw new Error("cli-bridge environment id has invalid retained route data");
56
+ }
57
+ return {
58
+ ...(backend === null ? {} : { backend }),
59
+ ...(model === null ? {} : { model }),
60
+ createDigest,
61
+ };
62
+ }
63
+ function optionalRouteValue(value) {
64
+ return value === null || routeValue(value);
65
+ }
66
+ function routeValue(value) {
67
+ return (typeof value === "string" &&
68
+ value.length > 0 &&
69
+ value.length <= MAX_ENVIRONMENT_ID_LENGTH &&
70
+ value.trim() === value);
71
+ }
72
+ function sha256Digest(value) {
73
+ return typeof value === "string" && /^sha256:[0-9a-f]{64}$/u.test(value);
74
+ }
75
+ function modelMatchesBackend(backend, model) {
76
+ if (model === null)
77
+ return backend === null;
78
+ return backend === null || model === backend || model.startsWith(`${backend}/`);
79
+ }
80
+ function assertEnvironmentRoute(backend, model, createDigest) {
81
+ if (!optionalRouteValue(backend ?? null) ||
82
+ !optionalRouteValue(model ?? null) ||
83
+ !sha256Digest(createDigest) ||
84
+ !modelMatchesBackend(backend ?? null, model ?? null)) {
85
+ throw new Error("cli-bridge environment route is invalid");
86
+ }
87
+ }
package/dist/index.js CHANGED
@@ -1,34 +1,70 @@
1
- import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider";
2
- import { harnessSystemPromptIntents, snapshotAgentProfile, } from "@tangle-network/agent-interface";
3
- import { createCliBridgeEnvironment } from "./retained-environment.js";
1
+ import { agentEnvironmentCreateInputDigest, createAgentEnvironmentWithIdempotency, } from "@tangle-network/agent-interface/environment-provider";
2
+ import { harnessTypeSchema, harnessSystemPromptIntents, snapshotAgentProfile, } from "@tangle-network/agent-interface";
3
+ import { createCliBridgeEnvironment, } from "./retained-environment.js";
4
+ import { cachedCliBridgeCapabilityDiscovery, narrowCliBridgeCapabilities, } from "./capability-discovery.js";
5
+ import { supportsCliBridgeNativeInteractions, } from "./retained-native.js";
4
6
  import { assertCliBridgeProviderOptions, } from "./provider-options.js";
5
7
  import { narrowedCliBridgeObservation } from "./observation.js";
8
+ import { cliBridgeEnvironmentId, cliBridgeEnvironmentRoute, } from "./environment-identity.js";
9
+ import { resolveBridgeModel } from "./wire.js";
6
10
  export { safeEndpointFromBaseUrl } from "./observation.js";
7
11
  export function createCliBridgeProvider(options) {
8
12
  assertCliBridgeProviderOptions(options);
9
13
  const name = options.name ?? "cli-bridge";
10
14
  const createRecords = new Map();
15
+ const configuredBackend = selectedBackendFromRoute(options.defaultModel);
16
+ const discoverCapabilities = cachedCliBridgeCapabilityDiscovery(options);
11
17
  // The observation surfaces are declared as intent and narrowed to the
12
18
  // sources this bridge can put a value on, so the environment offers the
13
19
  // operation exactly where the document claims it.
14
- const resolveCapabilities = () => {
15
- const declared = options.capabilities ?? defaultCliBridgeCapabilities();
16
- return declared.observation === undefined
20
+ const localCapabilities = (selectedBackend, allowNativeInteractions = false) => {
21
+ const parsedHarness = harnessTypeSchema.safeParse(selectedBackend);
22
+ const adapter = defaultCliBridgeCapabilities(parsedHarness.success ? parsedHarness.data : undefined);
23
+ const declared = options.capabilities === undefined
24
+ ? adapter
25
+ : narrowCliBridgeCapabilities(adapter, options.capabilities, selectedBackend);
26
+ const narrowed = declared.observation === undefined
17
27
  ? declared
18
28
  : {
19
29
  ...declared,
20
30
  observation: narrowedCliBridgeObservation(declared.observation, options),
21
31
  };
32
+ if (allowNativeInteractions &&
33
+ supportsCliBridgeNativeInteractions(narrowed, selectedBackend)) {
34
+ return narrowed;
35
+ }
36
+ const { interactions: _interactions, ...withoutInteractions } = narrowed;
37
+ return withoutInteractions;
38
+ };
39
+ const resolveCapabilities = (selectedBackend, model, allowNativeInteractions = false) => {
40
+ const local = localCapabilities(selectedBackend, allowNativeInteractions);
41
+ if (!allowNativeInteractions ||
42
+ options.capabilities !== undefined ||
43
+ selectedBackend !== "pi" ||
44
+ model === undefined) {
45
+ return local;
46
+ }
47
+ return discoverCapabilities(model).then((reported) => narrowCliBridgeCapabilities(local, reported, selectedBackend, {
48
+ preserveAdapterObservation: true,
49
+ }));
22
50
  };
23
51
  const createEnvironment = async (input) => {
24
52
  if (typeof input.profile === "string") {
25
53
  throw new Error(`createCliBridgeProvider requires an inline AgentProfile; named profile "${input.profile}" is unsupported`);
26
54
  }
55
+ const profile = snapshotAgentProfile(input.profile);
27
56
  const environmentInput = {
28
57
  ...input,
29
- profile: snapshotAgentProfile(input.profile),
58
+ profile,
30
59
  };
31
- const environmentId = input.idempotencyKey ?? crypto.randomUUID();
60
+ const selectedBackend = selectedBackendFromInput(environmentInput, configuredBackend);
61
+ const hasEnvironmentModel = selectedBackend !== undefined ||
62
+ options.defaultModel !== undefined ||
63
+ profile.model?.default !== undefined;
64
+ const model = hasEnvironmentModel
65
+ ? resolveBridgeModel(options, environmentInput, {}, profile)
66
+ : undefined;
67
+ const environmentId = cliBridgeEnvironmentId({ backend: selectedBackend, model }, agentEnvironmentCreateInputDigest(environmentInput), input.idempotencyKey);
32
68
  return createCliBridgeEnvironment({
33
69
  options,
34
70
  providerName: name,
@@ -36,12 +72,14 @@ export function createCliBridgeProvider(options) {
36
72
  environmentId,
37
73
  allowDispatch: true,
38
74
  cancelRunsOnDestroy: true,
39
- capabilities: resolveCapabilities(),
75
+ capabilities: await resolveCapabilities(selectedBackend, model, true),
76
+ selectedBackend,
77
+ selectedModel: model,
40
78
  });
41
79
  };
42
80
  return {
43
81
  name,
44
- capabilities: resolveCapabilities,
82
+ capabilities: () => resolveCapabilities(configuredBackend, options.defaultModel, true),
45
83
  create(input) {
46
84
  return createAgentEnvironmentWithIdempotency(createRecords, input, () => createEnvironment(input));
47
85
  },
@@ -49,18 +87,38 @@ export function createCliBridgeProvider(options) {
49
87
  if (id.length === 0 || id.trim() !== id) {
50
88
  throw new Error("cli-bridge environment id must be non-empty and have no outer whitespace");
51
89
  }
90
+ const route = cliBridgeEnvironmentRoute(id);
52
91
  return createCliBridgeEnvironment({
53
92
  options,
54
93
  providerName: name,
55
- environmentInput: { profile: { name: "reconnected" }, idempotencyKey: id },
94
+ environmentInput: {
95
+ profile: { name: "reconnected" },
96
+ ...(route.backend === undefined ? {} : { backend: route.backend }),
97
+ idempotencyKey: id,
98
+ },
56
99
  environmentId: id,
57
100
  allowDispatch: false,
58
101
  cancelRunsOnDestroy: false,
59
- capabilities: resolveCapabilities(),
102
+ capabilities: await resolveCapabilities(route.backend, route.model, true),
103
+ selectedBackend: route.backend,
104
+ selectedModel: route.model,
60
105
  });
61
106
  },
62
107
  };
63
108
  }
109
+ function selectedBackendFromInput(input, configuredBackend) {
110
+ if (input.backend !== undefined)
111
+ return input.backend;
112
+ if (typeof input.profile !== "string" && input.profile.harness !== undefined) {
113
+ return input.profile.harness;
114
+ }
115
+ return configuredBackend;
116
+ }
117
+ function selectedBackendFromRoute(route) {
118
+ const candidate = route?.split("/", 1)[0];
119
+ const parsed = harnessTypeSchema.safeParse(candidate);
120
+ return parsed.success ? parsed.data : undefined;
121
+ }
64
122
  /**
65
123
  * Describe the bridge backend, not this adapter.
66
124
  *
@@ -97,6 +155,19 @@ export function defaultCliBridgeCapabilities(harness) {
97
155
  eventIdentity: true,
98
156
  cancellationIdempotency: true,
99
157
  },
158
+ ...(harness === "pi"
159
+ ? {
160
+ interactions: {
161
+ kinds: ["permission"],
162
+ answerFieldTypes: ["select"],
163
+ responseScopes: ["interaction"],
164
+ secretAnswers: false,
165
+ concurrentRequests: false,
166
+ replay: true,
167
+ responseIdempotency: true,
168
+ },
169
+ }
170
+ : {}),
100
171
  workspace: {
101
172
  read: false,
102
173
  write: false,
@@ -0,0 +1,15 @@
1
+ import type { InteractionAcknowledgement, InteractionResponseCommand } from "@tangle-network/agent-interface";
2
+ import type { CliBridgeProviderOptions } from "./provider-options.js";
3
+ import { type CliBridgeTransport } from "./transport.js";
4
+ interface CliBridgeInteractionResponderOptions {
5
+ readonly sessionId?: string;
6
+ readonly providerName: string;
7
+ readonly environmentId: string;
8
+ readonly options: CliBridgeProviderOptions;
9
+ readonly transport: CliBridgeTransport;
10
+ }
11
+ /** Send one retry-safe response command to its exact retained CLI run. */
12
+ export declare function cliBridgeInteractionResponder(options: CliBridgeInteractionResponderOptions): (command: InteractionResponseCommand, operation?: {
13
+ signal?: AbortSignal;
14
+ }) => Promise<InteractionAcknowledgement>;
15
+ export {};
@@ -0,0 +1,100 @@
1
+ import { InteractionAcknowledgementSchema, InteractionResponseCommandSchema, } from "@tangle-network/agent-interface";
2
+ import { requestHeaders, trimSlash, } from "./transport.js";
3
+ function transportFailure(command, error, retryable = true) {
4
+ return InteractionAcknowledgementSchema.parse({
5
+ operationId: command.operationId,
6
+ binding: command.binding,
7
+ commandDigest: command.commandDigest,
8
+ status: "transport_failure",
9
+ message: `cli-bridge did not confirm the interaction response: ${error instanceof Error ? error.message : String(error)}`,
10
+ retryable,
11
+ });
12
+ }
13
+ function expectedHttpStatus(status) {
14
+ if (status === "accepted" || status === "already_resolved_same")
15
+ return 200;
16
+ if (status === "invalid_response")
17
+ return 400;
18
+ if (status === "unknown_run" || status === "unknown_interaction")
19
+ return 404;
20
+ if (status === "already_resolved_different" ||
21
+ status === "expired" ||
22
+ status === "cancelled" ||
23
+ status === "binding_mismatch")
24
+ return 409;
25
+ return 502;
26
+ }
27
+ function matchesHttpStatus(acknowledgement, status) {
28
+ if (status === expectedHttpStatus(acknowledgement.status))
29
+ return true;
30
+ // Bridge uses 429 only for a retryable rejection before the response can take effect.
31
+ return (status === 429 &&
32
+ acknowledgement.status === "transport_failure" &&
33
+ acknowledgement.retryable === true);
34
+ }
35
+ function classifyAcknowledgement(acknowledgement, status) {
36
+ if (!matchesHttpStatus(acknowledgement, status)) {
37
+ throw new Error(`cli-bridge interaction acknowledgement status ${JSON.stringify(acknowledgement.status)} contradicts HTTP ${status}`);
38
+ }
39
+ if (status >= 400 && status < 500 && acknowledgement.retryable === undefined) {
40
+ return { ...acknowledgement, retryable: false };
41
+ }
42
+ return acknowledgement;
43
+ }
44
+ function assertAcknowledgesCommand(acknowledgement, command) {
45
+ const actual = acknowledgement.binding;
46
+ const expected = command.binding;
47
+ if (acknowledgement.operationId !== command.operationId ||
48
+ acknowledgement.commandDigest !== command.commandDigest ||
49
+ actual.runId !== expected.runId ||
50
+ actual.provider !== expected.provider ||
51
+ actual.environmentId !== expected.environmentId ||
52
+ actual.sessionId !== expected.sessionId ||
53
+ actual.executionId !== expected.executionId ||
54
+ actual.interactionId !== expected.interactionId ||
55
+ actual.requestDigest !== expected.requestDigest) {
56
+ throw new Error("cli-bridge returned an interaction acknowledgement for another operation");
57
+ }
58
+ }
59
+ /** Send one retry-safe response command to its exact retained CLI run. */
60
+ export function cliBridgeInteractionResponder(options) {
61
+ return async (command, operation) => {
62
+ const exact = InteractionResponseCommandSchema.parse(command);
63
+ operation?.signal?.throwIfAborted();
64
+ if (exact.binding.provider !== options.providerName ||
65
+ exact.binding.environmentId !== options.environmentId ||
66
+ (options.sessionId !== undefined && exact.binding.sessionId !== options.sessionId)) {
67
+ return InteractionAcknowledgementSchema.parse({
68
+ operationId: exact.operationId,
69
+ binding: exact.binding,
70
+ commandDigest: exact.commandDigest,
71
+ status: "binding_mismatch",
72
+ message: "interaction response does not use this cli-bridge environment binding",
73
+ });
74
+ }
75
+ let response;
76
+ try {
77
+ response = await options.transport.fetch(`${trimSlash(options.options.baseUrl)}/v1/runs/${encodeURIComponent(exact.binding.runId)}/interactions/${encodeURIComponent(exact.binding.interactionId)}/respond`, {
78
+ method: "POST",
79
+ headers: requestHeaders(options.options),
80
+ body: JSON.stringify(exact),
81
+ ...(operation?.signal ? { signal: operation.signal } : {}),
82
+ });
83
+ }
84
+ catch (error) {
85
+ operation?.signal?.throwIfAborted();
86
+ return transportFailure(exact, error);
87
+ }
88
+ operation?.signal?.throwIfAborted();
89
+ let acknowledgement;
90
+ const retryableOnMalformed = response.status >= 500 || (response.status >= 200 && response.status < 300);
91
+ try {
92
+ acknowledgement = InteractionAcknowledgementSchema.parse(JSON.parse(await response.text()));
93
+ }
94
+ catch (error) {
95
+ return transportFailure(exact, error, retryableOnMalformed);
96
+ }
97
+ assertAcknowledgesCommand(acknowledgement, exact);
98
+ return classifyAcknowledgement(acknowledgement, response.status);
99
+ };
100
+ }
@@ -1,3 +1,4 @@
1
+ import { type StreamEvent } from "@tangle-network/agent-interface";
1
2
  import type { AgentEnvironmentEvent } from "@tangle-network/agent-interface/environment-provider";
2
3
  import type { CliBridgeSseFrame } from "./wire.js";
3
4
  interface CanonicalEventContext {
@@ -7,5 +8,11 @@ interface CanonicalEventContext {
7
8
  }
8
9
  /** Decode one explicitly typed retained-run frame without guessing its wire format. */
9
10
  export declare function retainedCanonicalEvent(frame: CliBridgeSseFrame, context: CanonicalEventContext): AgentEnvironmentEvent | undefined;
10
- export declare function isTerminalCanonicalEvent(event: AgentEnvironmentEvent): boolean;
11
+ export declare function isTerminalCanonicalEvent(event: AgentEnvironmentEvent): event is AgentEnvironmentEvent & {
12
+ normalized: Extract<StreamEvent, {
13
+ type: "status";
14
+ }> & {
15
+ status: "completed" | "failed" | "cancelled";
16
+ };
17
+ };
11
18
  export {};
@@ -57,7 +57,7 @@ async function performCliBridgeCancellation(options, transport, run) {
57
57
  throw new Error(`cli-bridge cancel ${response.status}: ${await response.text()}`);
58
58
  }
59
59
  let snapshot = cancelSnapshot(await response.text());
60
- assertCliBridgeRunSnapshotIdentity(snapshot, run.id);
60
+ assertCliBridgeRunSnapshotIdentity(snapshot, run.id, run.requestDigest);
61
61
  const waitBudgetMs = options.cancelWaitMs ?? 30_000;
62
62
  const deadline = Date.now() + waitBudgetMs;
63
63
  while (!snapshot.terminal) {
@@ -166,5 +166,7 @@ export function agentSessionStatusFromRun(snapshot) {
166
166
  return "failed";
167
167
  if (snapshot.status === "cancelled")
168
168
  return "cancelled";
169
+ if (snapshot.status === "unknown")
170
+ return "unknown";
169
171
  return "running";
170
172
  }
@@ -13,5 +13,7 @@ export interface CreateCliBridgeEnvironmentArgs {
13
13
  * action the document did not carry.
14
14
  */
15
15
  readonly capabilities: AgentEnvironmentCapabilities;
16
+ readonly selectedBackend?: string;
17
+ readonly selectedModel?: string;
16
18
  }
17
19
  export declare function createCliBridgeEnvironment(args: CreateCliBridgeEnvironmentArgs): AgentEnvironment;