@tangle-network/agent-provider-cli-bridge 0.7.8 → 0.8.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 +36 -0
- package/dist/capability-discovery.d.ts +15 -0
- package/dist/capability-discovery.js +164 -0
- package/dist/environment-identity.d.ts +13 -0
- package/dist/environment-identity.js +87 -0
- package/dist/index.js +83 -12
- package/dist/interaction-response.d.ts +15 -0
- package/dist/interaction-response.js +100 -0
- package/dist/retained-canonical-event.d.ts +8 -1
- package/dist/retained-control.js +3 -1
- package/dist/retained-environment.d.ts +2 -0
- package/dist/retained-environment.js +49 -7
- package/dist/retained-execution.d.ts +4 -3
- package/dist/retained-execution.js +79 -34
- package/dist/retained-native.d.ts +27 -0
- package/dist/retained-native.js +204 -0
- package/dist/retained-run-state.d.ts +1 -1
- package/dist/retained-run-state.js +4 -0
- package/dist/retained-stream.d.ts +1 -1
- package/dist/retained-stream.js +33 -6
- package/dist/wire.d.ts +7 -2
- package/dist/wire.js +107 -4
- package/package.json +13 -4
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
|
|
15
|
-
const
|
|
16
|
-
|
|
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
|
|
58
|
+
profile,
|
|
30
59
|
};
|
|
31
|
-
const
|
|
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: {
|
|
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):
|
|
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 {};
|
package/dist/retained-control.js
CHANGED
|
@@ -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;
|