@tangle-network/agent-provider-tangle 0.6.3 → 0.7.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
@@ -1,6 +1,7 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
- Wraps `@tangle-network/sandbox` 0.17 or newer as an `AgentEnvironmentProvider`.
3
+ Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
4
+ The peer range is `>=0.19.6 <1.0.0`; retained-run cancellation (`session.cancelRun`) first shipped in 0.19.6, and this package is developed and tested against 0.21.1.
4
5
 
5
6
  ```ts
6
7
  import { Sandbox } from '@tangle-network/sandbox'
@@ -15,13 +16,23 @@ Detached dispatch returns the immutable Sandbox execution receipt in `controlRef
15
16
  The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled.
16
17
  Reconstruct an exact session with `environment.session(reference.id, { controlRef: reference.controlRef })`; replay cursors are exclusive at both the agent interface and Sandbox session stream.
17
18
  Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session.
18
- Retained control is advertised only when the Sandbox exposes dispatch, replay, exact results and events, turn idempotency, and `cancelRun`; native continuation remains absent.
19
+ Session status with an exact control reference reports a state only when the payload names that execution; a payload bound to a different or unnamed execution reports `unknown`.
20
+
21
+ The provider claims `retainedControl` only from probed facts.
22
+ A lazy instance handle minted from the linked Sandbox SDK over the client's `fetch` transport must prove `dispatchPrompt`, `session`, and `cancelRun`, and the client must expose `get` for reconstruction; the probe sends no request and creates no resource.
23
+ The probe measures the linked SDK's method surface, not the connected service; service-side truth needs the sidecar capability endpoint and is a follow-up.
24
+ A client that cannot prove those facts gets no claim, so the runtime rejects retained dispatch before any sandbox is created.
25
+ Each concrete sandbox narrows the declared document independently against its own measured method surface, so a capable sandbox keeps retained control even when the provider-level claim failed closed.
26
+
27
+ Pass the SDK client itself when retained control matters.
28
+ An object-spread wrapper (`{ ...client }`) drops class prototype methods, including `fetch`, so the provider treats the wrapper as a non-SDK client and claims no retained control.
29
+ A wrapper must delegate the SDK methods instead of copying properties.
19
30
  After `session.prompt()` admits another turn, that session object's `controlRef` advances only when Sandbox returns the requested execution ID; a mismatched receipt fails without advancing local state.
20
31
  Sandbox keeps execution identifiers optional for older or unproven service paths, so this adapter fails closed when a dispatch or prompt does not return one and never falls back to latest-session state.
21
32
  Sessions reconstructed without a control reference may start a new prompt, but result lookup, cancellation, and cursor replay fail before calling Sandbox because those operations could otherwise select the newest unrelated execution.
22
33
  It also rejects `contextTransfer` and `nativeContinuation` inputs explicitly until those operations have native Sandbox support instead of silently dropping them.
23
- The default adapter does not advertise legacy checkpoint or fork operations because Sandbox 0.17 exposes snapshots and branches with different semantics.
24
- A custom compatible client may opt into the legacy methods explicitly; durable workspace branching remains unadvertised until checkpoint lookup, retry, conflict, and cleanup are implemented together.
34
+ The adapter never advertises `branching.checkpoint` or `branching.fork`.
35
+ Sandbox exposes `snapshot`, `listSnapshots`, `deleteSnapshot`, and `branch(count)` with different semantics; durable workspace branching stays unadvertised until the full `AgentWorkspaceBranching` contract — retry, lookup, conflict, and cleanup together — is implemented over that surface.
25
36
 
26
37
  Pass `exactProcess: {}` only when the Sandbox deployment supports `agent: false` creates and reports `metadata.runtimeMode: "control"`.
27
38
  The optional capability creates an ephemeral sandbox with an authenticated control service but no managed agent workload or agent credentials, explicit resources, exact blocked/domain egress, bounded binary file reads, shell-free launch, and recoverable process output plus terminal reason.
@@ -4,39 +4,54 @@ import type { SandboxClientLike, SandboxInstanceLike } from "./tangle-types.js";
4
4
  * The full capability document this adapter supports when the Sandbox client
5
5
  * implements every optional method.
6
6
  *
7
- * This is an upper bound, not a claim. `capabilitiesForSandbox()` narrows it
8
- * to what a specific client actually exposes, because a capability the client
9
- * cannot back becomes an action the caller selects and finds missing.
7
+ * This is an upper bound, not a claim. `capabilitiesForClient()` and
8
+ * `capabilitiesForSandbox()` narrow it to what the deployment actually
9
+ * exposes, because a capability the client cannot back becomes an action the
10
+ * caller selects and finds missing.
10
11
  */
11
12
  export declare function defaultTangleSandboxCapabilities(harness?: HarnessType): AgentEnvironmentCapabilities;
12
- /** Optional methods whose absence must clear the matching declared capability. */
13
+ /**
14
+ * Deployment facts that gate declared capabilities. Every fact defaults to
15
+ * false when it cannot be established; a false fact clears the matching
16
+ * declared capability.
17
+ */
13
18
  export interface SandboxCapabilitySupport {
19
+ /** The provider can rebuild an environment by id (`client.get`). */
20
+ reconstruct: boolean;
14
21
  dispatchPrompt: boolean;
15
22
  session: boolean;
16
23
  read: boolean;
17
24
  write: boolean;
18
25
  exec: boolean;
19
- checkpoint: boolean;
20
- fork: boolean;
21
26
  placement: boolean;
22
27
  destroy: boolean;
23
28
  cancelRun: boolean;
24
29
  }
25
30
  export declare function sandboxCapabilitySupport(box: SandboxInstanceLike, client: SandboxClientLike): SandboxCapabilitySupport;
26
31
  /**
27
- * Narrow provider-level claims to capabilities the client can actually back.
28
- *
29
- * A client without placement metadata cannot satisfy placement(), and this
30
- * adapter has no durable branching, interaction, or native-continuation
31
- * implementation regardless of an overly broad configured document.
32
- * Per-sandbox retained-run claims are narrowed later, after a concrete
33
- * session handle proves that cancelRun is available.
32
+ * Establish client-stage facts before any sandbox exists. Two sources:
33
+ * the client's own members (get, describePlacement) and, for an SDK-backed
34
+ * client, the linked SDK surface via `linkedSdkProbeInstance`. Retained
35
+ * control fails closed: without a probe handle nothing proves `cancelRun`,
36
+ * so the provider must not claim it. Box-scoped workspace and streaming
37
+ * facts stay at the declared upper bound when no handle can be minted —
38
+ * each concrete sandbox re-narrows them in `capabilitiesForSandbox`.
34
39
  */
35
- export declare function capabilitiesForClient(declared: AgentEnvironmentCapabilities, client: SandboxClientLike): AgentEnvironmentCapabilities;
40
+ export declare function clientCapabilitySupport(client: SandboxClientLike): SandboxCapabilitySupport;
36
41
  /**
37
- * Narrow a declared capability document to what this Sandbox instance backs.
42
+ * Narrow a declared capability document to established facts.
38
43
  *
39
44
  * Braid derives product actions from these flags, so an over-claimed flag is
40
- * an offered action that throws at the moment the user selects it.
45
+ * an offered action that throws at the moment the user selects it. Retained
46
+ * control requires the complete fact set: exact dispatch, a session handle,
47
+ * canonical cancellation, and environment reconstruction by id.
41
48
  */
49
+ export declare function narrowedTangleCapabilities(declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport): AgentEnvironmentCapabilities;
50
+ /**
51
+ * Narrow provider-level claims to facts the client can prove before any
52
+ * sandbox exists. `clientCapabilitySupport` documents which facts stay at
53
+ * the declared upper bound when the client offers no probe surface.
54
+ */
55
+ export declare function capabilitiesForClient(declared: AgentEnvironmentCapabilities, client: SandboxClientLike): AgentEnvironmentCapabilities;
56
+ /** Narrow a declared capability document to what this Sandbox instance backs. */
42
57
  export declare function capabilitiesForSandbox(declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport): AgentEnvironmentCapabilities;
@@ -1,11 +1,13 @@
1
1
  import { harnessSystemPromptIntents } from "@tangle-network/agent-interface";
2
+ import { SandboxInstance } from "@tangle-network/sandbox";
2
3
  /**
3
4
  * The full capability document this adapter supports when the Sandbox client
4
5
  * implements every optional method.
5
6
  *
6
- * This is an upper bound, not a claim. `capabilitiesForSandbox()` narrows it
7
- * to what a specific client actually exposes, because a capability the client
8
- * cannot back becomes an action the caller selects and finds missing.
7
+ * This is an upper bound, not a claim. `capabilitiesForClient()` and
8
+ * `capabilitiesForSandbox()` narrow it to what the deployment actually
9
+ * exposes, because a capability the client cannot back becomes an action the
10
+ * caller selects and finds missing.
9
11
  */
10
12
  export function defaultTangleSandboxCapabilities(harness) {
11
13
  return {
@@ -31,11 +33,22 @@ export function defaultTangleSandboxCapabilities(harness) {
31
33
  validation: true,
32
34
  },
33
35
  streaming: { live: true, replay: true, detach: true, turnIdempotency: true },
34
- // A Sandbox session id is not a Braid context-boundary proof. Native
35
- // continuation stays unavailable until this adapter can verify one.
36
- // Retained control stays opt-in until a concrete session exposes cancelRun.
37
- sessions: { continue: false, list: false, messages: false },
36
+ // Retained control is declared as intent here and stripped by narrowing
37
+ // wherever the facts cannot prove dispatchPrompt, session, cancelRun,
38
+ // and environment reconstruction by id. The four sub-flags are
39
+ // all-or-nothing by design: this adapter implements the identities
40
+ // together over one Sandbox surface, and the capability schema refuses
41
+ // a partial block, so they stand or fall on the same probed fact set.
42
+ sessions: { continue: true, list: false, messages: false },
43
+ retainedControl: {
44
+ exactRunIdentity: true,
45
+ resultIdentity: true,
46
+ eventIdentity: true,
47
+ cancellationIdempotency: true,
48
+ },
38
49
  workspace: { read: true, write: true, exec: true, git: false, upload: true, download: true },
50
+ // Sandbox exposes snapshot/branch, not the checkpoint/fork contract, and
51
+ // durable branching needs retry, lookup, conflict, and cleanup together.
39
52
  branching: { checkpoint: false, fork: false },
40
53
  placement: true,
41
54
  usage: false,
@@ -44,128 +57,153 @@ export function defaultTangleSandboxCapabilities(harness) {
44
57
  confidential: false,
45
58
  };
46
59
  }
47
- const CAPABILITY_PROBE_SESSION_ID = "__tangle-capability-probe__";
60
+ // One reserved id names both probe handles; neither ever reaches the service.
61
+ const CAPABILITY_PROBE_ID = "__tangle-capability-probe__";
48
62
  export function sandboxCapabilitySupport(box, client) {
49
63
  let session;
50
64
  if (typeof box.session === "function") {
51
65
  try {
52
66
  // Sandbox session handles are lazy. Inspecting one does not call the
53
67
  // service, and keeps retained-control claims tied to the actual handle.
54
- session = box.session(CAPABILITY_PROBE_SESSION_ID);
68
+ session = box.session(CAPABILITY_PROBE_ID);
55
69
  }
56
70
  catch {
57
71
  // A client that cannot produce a session handle cannot prove retained control.
58
72
  }
59
73
  }
60
74
  return {
75
+ reconstruct: typeof client.get === "function",
61
76
  dispatchPrompt: typeof box.dispatchPrompt === "function",
62
77
  session: typeof box.session === "function",
63
78
  read: typeof box.read === "function",
64
79
  write: typeof box.write === "function",
65
80
  exec: typeof box.exec === "function",
66
- checkpoint: typeof box.checkpoint === "function",
67
- fork: typeof box.fork === "function",
68
81
  placement: typeof client.describePlacement === "function",
69
82
  destroy: typeof box.delete === "function",
70
83
  cancelRun: typeof session?.cancelRun === "function",
71
84
  };
72
85
  }
73
86
  /**
74
- * Narrow provider-level claims to capabilities the client can actually back.
75
- *
76
- * A client without placement metadata cannot satisfy placement(), and this
77
- * adapter has no durable branching, interaction, or native-continuation
78
- * implementation regardless of an overly broad configured document.
79
- * Per-sandbox retained-run claims are narrowed later, after a concrete
80
- * session handle proves that cancelRun is available.
87
+ * Mint a lazy instance handle from the sandbox SDK linked into this process.
88
+ * The handle measures the LINKED SDK's instance and session method surface —
89
+ * an adapter-capability fact, not deployment truth. It is valid exactly when
90
+ * the client is SDK-backed (carries the SDK `fetch` transport), because the
91
+ * sandboxes such a client returns are instances of these same classes.
92
+ * Deployment truth (what the connected service honors) needs the sidecar
93
+ * capability endpoint and is a follow-up. The handle and its probe session
94
+ * never leave the process: construction and `session(id)` are lazy in the
95
+ * SDK, so no request is sent and no billable resource is created.
81
96
  */
82
- export function capabilitiesForClient(declared, client) {
83
- const canReconstructRetainedEnvironment = typeof client.get === "function";
84
- const narrowed = {
85
- ...declared,
86
- streaming: {
87
- ...declared.streaming,
88
- replay: declared.streaming.replay,
89
- detach: declared.streaming.detach,
90
- turnIdempotency: declared.streaming.turnIdempotency,
91
- },
92
- sessions: {
93
- ...declared.sessions,
94
- continue: declared.sessions.continue && canReconstructRetainedEnvironment,
95
- list: false,
96
- messages: false,
97
- },
98
- workspace: { ...declared.workspace, git: false },
99
- usage: false,
100
- branching: {
101
- ...declared.branching,
102
- checkpoint: false,
103
- fork: false,
104
- ...(declared.branching.retrySafe !== undefined ? { retrySafe: false } : {}),
105
- ...(declared.branching.lookup !== undefined ? { lookup: false } : {}),
106
- ...(declared.branching.cleanup !== undefined ? { cleanup: false } : {}),
107
- },
108
- placement: declared.placement && typeof client.describePlacement === "function",
97
+ function linkedSdkProbeInstance(client) {
98
+ if (typeof client.fetch !== "function")
99
+ return undefined;
100
+ try {
101
+ return new SandboxInstance(client, {
102
+ id: CAPABILITY_PROBE_ID,
103
+ status: "stopped",
104
+ createdAt: new Date(0),
105
+ });
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
111
+ /**
112
+ * Establish client-stage facts before any sandbox exists. Two sources:
113
+ * the client's own members (get, describePlacement) and, for an SDK-backed
114
+ * client, the linked SDK surface via `linkedSdkProbeInstance`. Retained
115
+ * control fails closed: without a probe handle nothing proves `cancelRun`,
116
+ * so the provider must not claim it. Box-scoped workspace and streaming
117
+ * facts stay at the declared upper bound when no handle can be minted —
118
+ * each concrete sandbox re-narrows them in `capabilitiesForSandbox`.
119
+ */
120
+ export function clientCapabilitySupport(client) {
121
+ const probe = linkedSdkProbeInstance(client);
122
+ if (probe)
123
+ return sandboxCapabilitySupport(probe, client);
124
+ return {
125
+ reconstruct: typeof client.get === "function",
126
+ dispatchPrompt: true,
127
+ session: true,
128
+ read: true,
129
+ write: true,
130
+ exec: true,
131
+ placement: typeof client.describePlacement === "function",
132
+ destroy: true,
133
+ cancelRun: false,
109
134
  };
110
- delete narrowed.interactions;
111
- if (!canReconstructRetainedEnvironment)
112
- delete narrowed.retainedControl;
113
- delete narrowed.nativeContinuation;
114
- return narrowed;
115
135
  }
116
136
  /**
117
- * Narrow a declared capability document to what this Sandbox instance backs.
137
+ * Narrow a declared capability document to established facts.
118
138
  *
119
139
  * Braid derives product actions from these flags, so an over-claimed flag is
120
- * an offered action that throws at the moment the user selects it.
140
+ * an offered action that throws at the moment the user selects it. Retained
141
+ * control requires the complete fact set: exact dispatch, a session handle,
142
+ * canonical cancellation, and environment reconstruction by id.
121
143
  */
122
- export function capabilitiesForSandbox(declared, support) {
123
- const narrowed = { ...declared };
124
- const supportsRetainedControl = declared.sessions.continue &&
125
- declared.streaming.detach &&
126
- declared.streaming.replay &&
127
- declared.streaming.turnIdempotency &&
144
+ export function narrowedTangleCapabilities(declared, support) {
145
+ const supportsRetainedControl = declared.sessions.continue === true &&
146
+ declared.streaming.detach === true &&
147
+ declared.streaming.replay === true &&
148
+ declared.streaming.turnIdempotency === true &&
149
+ support.reconstruct &&
128
150
  support.dispatchPrompt &&
129
151
  support.session &&
130
152
  support.cancelRun;
131
- delete narrowed.interactions;
132
- delete narrowed.nativeContinuation;
133
- if (!supportsRetainedControl)
134
- delete narrowed.retainedControl;
135
- return {
136
- ...narrowed,
153
+ // A cleared fact forces false; a held fact passes the declared value
154
+ // through unchanged, so a malformed declaration still reaches the schema
155
+ // at the provider boundary instead of being laundered into a boolean.
156
+ const narrowed = {
157
+ ...declared,
137
158
  streaming: {
138
159
  ...declared.streaming,
139
- detach: declared.streaming.detach && support.dispatchPrompt,
140
- replay: declared.streaming.replay && support.session,
141
- turnIdempotency: declared.streaming.turnIdempotency && support.session,
160
+ detach: support.dispatchPrompt ? declared.streaming.detach : false,
161
+ replay: support.session ? declared.streaming.replay : false,
162
+ turnIdempotency: support.session
163
+ ? declared.streaming.turnIdempotency
164
+ : false,
142
165
  },
143
166
  sessions: {
144
167
  ...declared.sessions,
145
- continue: declared.sessions.continue && supportsRetainedControl,
168
+ continue: supportsRetainedControl ? declared.sessions.continue : false,
146
169
  list: false,
147
170
  messages: false,
148
171
  },
149
172
  workspace: {
150
173
  ...declared.workspace,
151
- read: declared.workspace.read && support.read,
152
- write: declared.workspace.write && support.write,
153
- exec: declared.workspace.exec && support.exec,
174
+ read: support.read ? declared.workspace.read : false,
175
+ write: support.write ? declared.workspace.write : false,
176
+ exec: support.exec ? declared.workspace.exec : false,
154
177
  git: false,
155
- upload: declared.workspace.upload && support.write,
156
- download: declared.workspace.download && support.read,
178
+ upload: support.write ? declared.workspace.upload : false,
179
+ download: support.read ? declared.workspace.download : false,
157
180
  },
158
181
  branching: {
159
- ...narrowed.branching,
182
+ ...declared.branching,
160
183
  checkpoint: false,
161
184
  fork: false,
162
- ...(declared.branching.retrySafe !== undefined
163
- ? { retrySafe: false }
164
- : {}),
185
+ ...(declared.branching.retrySafe !== undefined ? { retrySafe: false } : {}),
165
186
  ...(declared.branching.lookup !== undefined ? { lookup: false } : {}),
166
187
  ...(declared.branching.cleanup !== undefined ? { cleanup: false } : {}),
167
188
  },
168
- placement: narrowed.placement && support.placement,
189
+ placement: support.placement ? declared.placement : false,
169
190
  usage: false,
170
191
  };
192
+ delete narrowed.interactions;
193
+ delete narrowed.nativeContinuation;
194
+ if (!supportsRetainedControl)
195
+ delete narrowed.retainedControl;
196
+ return narrowed;
197
+ }
198
+ /**
199
+ * Narrow provider-level claims to facts the client can prove before any
200
+ * sandbox exists. `clientCapabilitySupport` documents which facts stay at
201
+ * the declared upper bound when the client offers no probe surface.
202
+ */
203
+ export function capabilitiesForClient(declared, client) {
204
+ return narrowedTangleCapabilities(declared, clientCapabilitySupport(client));
205
+ }
206
+ /** Narrow a declared capability document to what this Sandbox instance backs. */
207
+ export function capabilitiesForSandbox(declared, support) {
208
+ return narrowedTangleCapabilities(declared, support);
171
209
  }
@@ -33,17 +33,10 @@ export async function interruptAfterAbort(box, reference) {
33
33
  // Only a receipt that proves this call admitted new work may be interrupted.
34
34
  if (metadata?.dispatched !== true || metadata.alreadyExisted === true)
35
35
  return;
36
- const sessionId = reference.id;
37
36
  const executionId = reference.controlRef?.executionId;
38
37
  if (!box.session || executionId === undefined)
39
38
  return;
40
- try {
41
- await box.session(sessionId)?.interrupt({ executionId });
42
- }
43
- catch {
44
- // The original abort remains the caller-visible outcome; the provider has
45
- // no stronger cleanup primitive when the late dispatch result is lost.
46
- }
39
+ await interruptExecutionAfterAbort(box, reference.id, executionId);
47
40
  }
48
41
  export async function interruptExecutionAfterAbort(source, sessionId, executionId) {
49
42
  try {
@@ -4,7 +4,7 @@ import { AgentRunCancellationAcknowledgementSchema, AgentRunCancellationRequestS
4
4
  import { environmentEventFromSandboxEvent, isSandboxConnectionMarker, sandboxEventIdentity, } from "./tangle-events.js";
5
5
  import { agentTurnResultFromPromptRecord, promptFromTurnInput, promptOptionsFromTurnInput, validatedSandboxPromptResult, } from "./tangle-prompt.js";
6
6
  import { retainedSessionControlRef, resolveRetainedSessionControlRef, sameRunControlRef, sessionPromptExecutionId, } from "./tangle-session-control.js";
7
- import { sessionStatusFromUnknown } from "./tangle-environment-values.js";
7
+ import { executionBoundSessionStatus, sessionStatusFromUnknown, } from "./tangle-environment-values.js";
8
8
  import { awaitWithSignal, boundedIdentifier, } from "./tangle-contract-safety.js";
9
9
  import { assertOptionKeys } from "./tangle-environment-validation.js";
10
10
  import { hasReplayPayload, interruptExecutionAfterAbort, sessionPromptRequestDigest, } from "./tangle-environment-control.js";
@@ -47,6 +47,12 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
47
47
  options?.signal?.throwIfAborted();
48
48
  if (!status)
49
49
  return null;
50
+ const expectedExecutionId = activeControlRef?.executionId;
51
+ // Sandbox reports session-wide status. With an exact control reference,
52
+ // the answer is only valid when the payload binds to that execution.
53
+ if (expectedExecutionId !== undefined) {
54
+ return executionBoundSessionStatus(status, expectedExecutionId);
55
+ }
50
56
  return sessionStatusFromUnknown(status.status);
51
57
  },
52
58
  async *events(options) {
@@ -1,11 +1,4 @@
1
- import type { CheckpointRef, CheckpointRequest, ExecRequest, ForkRequest } from "@tangle-network/agent-interface/environment-provider";
1
+ import type { ExecRequest } from "@tangle-network/agent-interface/environment-provider";
2
2
  export declare function assertOptionKeys(value: object | undefined, allowed: readonly string[], label: string): void;
3
3
  export declare function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown>;
4
4
  export declare function assertExecOptions(options: ExecRequest | undefined): void;
5
- export declare function assertCheckpointOptions(options: (CheckpointRequest & {
6
- signal?: AbortSignal;
7
- }) | undefined): void;
8
- export declare function assertCheckpointRef(checkpoint: CheckpointRef): void;
9
- export declare function assertForkOptions(options: (ForkRequest & {
10
- signal?: AbortSignal;
11
- }) | undefined): void;
@@ -36,28 +36,3 @@ export function assertExecOptions(options) {
36
36
  throw new Error("Tangle exec timeoutMs must be a positive safe integer");
37
37
  }
38
38
  }
39
- export function assertCheckpointOptions(options) {
40
- assertOptionKeys(options, ["name", "metadata", "signal"], "Tangle checkpoint");
41
- if (options?.name !== undefined)
42
- boundedString(options.name, "Tangle checkpoint name");
43
- if (options?.metadata !== undefined)
44
- assertRecord(options.metadata, "Tangle checkpoint metadata");
45
- }
46
- export function assertCheckpointRef(checkpoint) {
47
- if (!checkpoint || typeof checkpoint !== "object" || Array.isArray(checkpoint)) {
48
- throw new Error("Tangle fork checkpoint must be an object");
49
- }
50
- assertOptionKeys(checkpoint, ["id", "provider", "metadata"], "Tangle fork checkpoint");
51
- boundedIdentifier(checkpoint.id, "Tangle checkpoint id");
52
- if (checkpoint.provider !== undefined)
53
- boundedIdentifier(checkpoint.provider, "Tangle checkpoint provider");
54
- if (checkpoint.metadata !== undefined)
55
- assertRecord(checkpoint.metadata, "Tangle checkpoint metadata");
56
- }
57
- export function assertForkOptions(options) {
58
- assertOptionKeys(options, ["name", "metadata", "signal"], "Tangle fork");
59
- if (options?.name !== undefined)
60
- boundedString(options.name, "Tangle fork name");
61
- if (options?.metadata !== undefined)
62
- assertRecord(options.metadata, "Tangle fork metadata");
63
- }
@@ -2,7 +2,17 @@ import type { AgentEnvironmentStatus, AgentSessionStatus, PlacementInfo } from "
2
2
  import type { SandboxInstanceLike } from "./tangle-types.js";
3
3
  export declare function nonEmptyString(value: unknown): string | undefined;
4
4
  export declare function optionalNonEmptyString(value: unknown, label: string): string | undefined;
5
- export declare function checkpointIdFromResult(result: unknown): string;
6
5
  export declare function placementInfoFromLoopPlacement(placement: unknown, box: SandboxInstanceLike): PlacementInfo;
7
6
  export declare function statusFromUnknown(status: unknown): AgentEnvironmentStatus;
8
7
  export declare function sessionStatusFromUnknown(status: unknown): AgentSessionStatus;
8
+ /**
9
+ * Bind a session-wide status payload to one exact execution.
10
+ *
11
+ * The Sandbox status endpoint reports the whole session. Its execution
12
+ * identity fields — activeExecutionId, latestExecutionId, runControlRef,
13
+ * failureReason.executionId — are the only proof of which execution the
14
+ * lifecycle state describes. A payload that names a different execution, or
15
+ * none, must not be attributed to the exact run, so the result degrades to
16
+ * "unknown" instead of fabricating an execution-scoped answer.
17
+ */
18
+ export declare function executionBoundSessionStatus(payload: unknown, executionId: string): AgentSessionStatus;
@@ -1,4 +1,3 @@
1
- import { assertBoundedJson } from "./tangle-contract-safety.js";
2
1
  const MAX_IDENTIFIER_LENGTH = 512;
3
2
  export function nonEmptyString(value) {
4
3
  return typeof value === "string" &&
@@ -19,18 +18,6 @@ export function optionalNonEmptyString(value, label) {
19
18
  }
20
19
  return value;
21
20
  }
22
- export function checkpointIdFromResult(result) {
23
- assertBoundedJson(result);
24
- const record = result && typeof result === "object" ? result : {};
25
- const id = record.checkpointId ?? record.id;
26
- if (typeof id !== "string" ||
27
- id.length === 0 ||
28
- id.length > MAX_IDENTIFIER_LENGTH ||
29
- id.trim() !== id) {
30
- throw new Error("sandbox checkpoint returned no checkpoint id");
31
- }
32
- return id;
33
- }
34
21
  export function placementInfoFromLoopPlacement(placement, box) {
35
22
  if (!placement || typeof placement !== "object") {
36
23
  return { kind: "sandbox", sandboxId: boundedId(box.id, "sandbox id") };
@@ -75,6 +62,10 @@ export function statusFromUnknown(status) {
75
62
  return status;
76
63
  if (status === "completed" || status === "cancelled")
77
64
  return "stopped";
65
+ // A queued session is admitted work that has not started: pending, not
66
+ // unknown — "unknown" also means "not attributable" in exact-status binding.
67
+ if (status === "queued")
68
+ return "pending";
78
69
  return "unknown";
79
70
  }
80
71
  export function sessionStatusFromUnknown(status) {
@@ -82,3 +73,47 @@ export function sessionStatusFromUnknown(status) {
82
73
  return status;
83
74
  return statusFromUnknown(status);
84
75
  }
76
+ /**
77
+ * Bind a session-wide status payload to one exact execution.
78
+ *
79
+ * The Sandbox status endpoint reports the whole session. Its execution
80
+ * identity fields — activeExecutionId, latestExecutionId, runControlRef,
81
+ * failureReason.executionId — are the only proof of which execution the
82
+ * lifecycle state describes. A payload that names a different execution, or
83
+ * none, must not be attributed to the exact run, so the result degrades to
84
+ * "unknown" instead of fabricating an execution-scoped answer.
85
+ */
86
+ export function executionBoundSessionStatus(payload, executionId) {
87
+ const record = payload && typeof payload === "object" && !Array.isArray(payload)
88
+ ? payload
89
+ : {};
90
+ const sessionStatus = sessionStatusFromUnknown(record.status);
91
+ const active = nonEmptyString(record.activeExecutionId);
92
+ const latest = nonEmptyString(record.latestExecutionId);
93
+ const admitted = record.runControlRef && typeof record.runControlRef === "object"
94
+ ? nonEmptyString(record.runControlRef.executionId)
95
+ : undefined;
96
+ const failed = record.failureReason && typeof record.failureReason === "object"
97
+ ? nonEmptyString(record.failureReason.executionId)
98
+ : undefined;
99
+ // An attributed failure outranks liveness: failureReason.executionId is the
100
+ // only field that names the execution its state describes, so it decides
101
+ // the failed case in both directions. Naming this execution proves the
102
+ // failure; naming another execution proves the failed state is not this
103
+ // run's, even when a contradictory payload also marks this execution live.
104
+ if (sessionStatus === "failed" && failed !== undefined) {
105
+ return failed === executionId ? "failed" : "unknown";
106
+ }
107
+ // A live execution owns the session's current state; any other live
108
+ // execution means this payload says nothing exact about the bound run.
109
+ if (active !== undefined) {
110
+ return active === executionId ? sessionStatus : "unknown";
111
+ }
112
+ // With nothing live, the state belongs to the newest execution. Trust the
113
+ // admitted-run reference only when no newer execution is named.
114
+ if (latest === executionId)
115
+ return sessionStatus;
116
+ if (latest === undefined && admitted === executionId)
117
+ return sessionStatus;
118
+ return "unknown";
119
+ }
@@ -2,11 +2,11 @@ import { AgentTurnInputSchema } from "@tangle-network/agent-interface";
2
2
  import { environmentEventFromSandboxEvent } from "./tangle-events.js";
3
3
  import { executionIdFromTurnInput, promptFromTurnInput, promptOptionsFromTurnInput, } from "./tangle-prompt.js";
4
4
  import { resolveRetainedSessionControlRef } from "./tangle-session-control.js";
5
- import { checkpointIdFromResult, placementInfoFromLoopPlacement, statusFromUnknown, } from "./tangle-environment-values.js";
5
+ import { placementInfoFromLoopPlacement, statusFromUnknown, } from "./tangle-environment-values.js";
6
6
  import { execResultFromSandboxExecResult } from "./tangle-result-values.js";
7
7
  import { capabilitiesForSandbox, sandboxCapabilitySupport } from "./tangle-capabilities.js";
8
- import { attachCleanupHandle, awaitWithSignal, assertBoundedJson, boundedIdentifier, boundedString, } from "./tangle-contract-safety.js";
9
- import { assertCheckpointOptions, assertCheckpointRef, assertExecOptions, assertForkOptions, assertOptionKeys, } from "./tangle-environment-validation.js";
8
+ import { awaitWithSignal, assertBoundedJson, boundedIdentifier, boundedString, } from "./tangle-contract-safety.js";
9
+ import { assertExecOptions, assertOptionKeys, } from "./tangle-environment-validation.js";
10
10
  import { interruptExecutionAfterAbort, } from "./tangle-environment-control.js";
11
11
  import { dispatchEnvironmentRun } from "./tangle-environment-dispatch.js";
12
12
  import { sandboxSessionAsAgentSession } from "./tangle-environment-session.js";
@@ -104,6 +104,10 @@ export function sandboxInstanceAsEnvironment(box, providerName, client, declared
104
104
  throw new Error("sandbox session(id) returned an unrelated session");
105
105
  }
106
106
  const agentSession = sandboxSessionAsAgentSession(session, resolveRetainedSessionControlRef(options?.controlRef, id, providerName, environmentId), providerName, environmentId, dispatch, exactExecutionEvents);
107
+ // sessions.continue was granted from a probe-session fact; this
108
+ // backstop holds every concrete session to that fact, so a client
109
+ // whose sessions diverge from its probe surface fails loud here
110
+ // instead of failing at the first cancellation.
107
111
  if (capabilities.sessions.continue &&
108
112
  typeof agentSession.cancelRun !== "function") {
109
113
  throw new Error("Tangle retained session support requires SandboxSession.cancelRun");
@@ -152,56 +156,6 @@ export function sandboxInstanceAsEnvironment(box, providerName, client, declared
152
156
  },
153
157
  }
154
158
  : {}),
155
- ...(capabilities.branching.checkpoint && box.checkpoint
156
- ? {
157
- async checkpoint(options) {
158
- assertCheckpointOptions(options);
159
- options?.signal?.throwIfAborted();
160
- const result = await awaitWithSignal(box.checkpoint?.(options), options?.signal);
161
- options?.signal?.throwIfAborted();
162
- return { id: checkpointIdFromResult(result), provider: providerName };
163
- },
164
- }
165
- : {}),
166
- ...(capabilities.branching.fork && box.fork
167
- ? {
168
- async fork(checkpoint, options) {
169
- assertCheckpointRef(checkpoint);
170
- assertForkOptions(options);
171
- if (checkpoint.provider !== undefined && checkpoint.provider !== providerName) {
172
- throw new Error("Tangle fork checkpoint belongs to another provider");
173
- }
174
- boundedIdentifier(checkpoint.id, "Tangle checkpoint id");
175
- options?.signal?.throwIfAborted();
176
- const forked = await awaitWithSignal(box.fork?.(checkpoint.id, options), options?.signal);
177
- if (!forked)
178
- throw new Error("sandbox fork returned no environment");
179
- try {
180
- options?.signal?.throwIfAborted();
181
- if (boundedIdentifier(forked.id, "Tangle fork environment id") === environmentId) {
182
- throw new Error("Tangle fork returned the source environment");
183
- }
184
- return sandboxInstanceAsEnvironment(forked, providerName, client, capabilities);
185
- }
186
- catch (error) {
187
- if (!forked.delete) {
188
- const baseError = error instanceof Error ? error : new Error(String(error));
189
- attachCleanupHandle(baseError, forked);
190
- throw baseError;
191
- }
192
- try {
193
- await forked.delete();
194
- }
195
- catch (cleanupError) {
196
- const combined = new AggregateError([error, cleanupError], "Tangle fork validation and cleanup both failed");
197
- attachCleanupHandle(combined, forked, cleanupError);
198
- throw combined;
199
- }
200
- throw error;
201
- }
202
- },
203
- }
204
- : {}),
205
159
  ...(capabilities.placement
206
160
  ? {
207
161
  async placement(options) {
@@ -1,14 +1,13 @@
1
- import type { PromptResult } from "@tangle-network/sandbox";
1
+ import type { PromptOptions, PromptResult } from "@tangle-network/sandbox";
2
2
  import type { AgentTurnInput, AgentTurnResult } from "@tangle-network/agent-interface/environment-provider";
3
3
  import type { AgentExactRunControlRef, InputPart } from "@tangle-network/agent-interface";
4
- import type { TanglePromptOptions } from "./tangle-types.js";
5
4
  export declare function promptFromTurnInput(input: AgentTurnInput): string | InputPart[];
6
5
  export declare function executionIdFromTurnInput(input: AgentTurnInput): string | undefined;
7
6
  export declare function promptOptionsFromTurnInput(input: AgentTurnInput, target: {
8
7
  provider: string;
9
8
  environmentId: string;
10
9
  sessionId?: string;
11
- }): TanglePromptOptions;
10
+ }): PromptOptions;
12
11
  type SandboxRunStatus = "success" | "failed" | "blocked_on_approval" | "awaiting_question" | "awaiting_plan_decision";
13
12
  type ValidatedSandboxPromptResult = Record<string, unknown> & {
14
13
  success: boolean;
@@ -15,7 +15,7 @@ export function createTangleProvider(options) {
15
15
  providerName,
16
16
  })
17
17
  : undefined;
18
- const resolveCapabilities = async () => {
18
+ const resolveDeclaredCapabilities = async () => {
19
19
  const configured = options.capabilities
20
20
  ? typeof options.capabilities === "function"
21
21
  ? await options.capabilities()
@@ -24,14 +24,17 @@ export function createTangleProvider(options) {
24
24
  if (!exactProcess && configured.exactProcess) {
25
25
  throw new Error("Tangle capabilities cannot advertise exactProcess without exactProcess configuration");
26
26
  }
27
- const withExactProcess = exactProcess
27
+ return exactProcess
28
28
  ? {
29
29
  ...configured,
30
30
  exactProcess: { egress: ["blocked", "strict"] },
31
31
  }
32
32
  : configured;
33
- return AgentEnvironmentCapabilitiesSchema.parse(capabilitiesForClient(withExactProcess, options.client));
34
33
  };
34
+ // Provider-boundary document: client-stage facts only. It also validates
35
+ // the configured document, so create() and get() call it before any effect.
36
+ const narrowedProviderCapabilities = (declared) => AgentEnvironmentCapabilitiesSchema.parse(capabilitiesForClient(declared, options.client));
37
+ const resolveCapabilities = async () => narrowedProviderCapabilities(await resolveDeclaredCapabilities());
35
38
  return {
36
39
  name: providerName,
37
40
  ...(exactProcess ? { exactProcess } : {}),
@@ -44,7 +47,11 @@ export function createTangleProvider(options) {
44
47
  if (input.providerOptions && Object.keys(input.providerOptions).length > 0) {
45
48
  throw new Error("Tangle create providerOptions are not supported");
46
49
  }
47
- const capabilities = await resolveCapabilities();
50
+ // The sandbox stage narrows from the declared document, not the
51
+ // provider-boundary one: the client stage cannot observe box-scoped
52
+ // facts, so measured instance facts must decide them per sandbox.
53
+ const declaredCapabilities = await resolveDeclaredCapabilities();
54
+ narrowedProviderCapabilities(declaredCapabilities);
48
55
  const createOptions = options.mapCreateInput?.(input) ??
49
56
  sandboxOptionsFromCreateInput(input, options.defaultBackend ?? "opencode");
50
57
  assertMappedCreateOptions(createOptions);
@@ -76,7 +83,7 @@ export function createTangleProvider(options) {
76
83
  }
77
84
  try {
78
85
  input.signal?.throwIfAborted();
79
- const environment = sandboxInstanceAsEnvironment(box, providerName, options.client, capabilities);
86
+ const environment = sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities);
80
87
  input.signal?.throwIfAborted();
81
88
  return environment;
82
89
  }
@@ -101,14 +108,14 @@ export function createTangleProvider(options) {
101
108
  async get(id, operation) {
102
109
  assertProviderOperationOptions(operation, "Tangle get");
103
110
  boundedIdentifier(id, "Tangle environment id");
111
+ const declaredCapabilities = await resolveDeclaredCapabilities();
112
+ narrowedProviderCapabilities(declaredCapabilities);
104
113
  operation?.signal?.throwIfAborted();
105
114
  const box = await awaitWithSignal(options.client.get?.(id, operation), operation?.signal);
106
115
  operation?.signal?.throwIfAborted();
107
116
  if (!box || boundedIdentifier(box.id, "Tangle environment id") !== id)
108
117
  return null;
109
- return box
110
- ? sandboxInstanceAsEnvironment(box, providerName, options.client, await resolveCapabilities())
111
- : null;
118
+ return sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities);
112
119
  },
113
120
  }
114
121
  : {}),
@@ -1,9 +1,6 @@
1
1
  import type { BackendType, CreateSandboxOptions, ExecResult as SandboxExecResult, PromptOptions, PromptResult, SandboxEvent } from "@tangle-network/sandbox";
2
- import type { AgentExactRunControlRef, AgentRunCancellationAcknowledgement, AgentRunCancellationRequest, InputPart } from "@tangle-network/agent-interface";
2
+ import type { AgentRunCancellationAcknowledgement, AgentRunCancellationRequest, InputPart } from "@tangle-network/agent-interface";
3
3
  import type { AgentEnvironmentCapabilities, AgentEnvironmentProvider, CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
4
- export type TanglePromptOptions = PromptOptions & {
5
- runControlRef?: AgentExactRunControlRef;
6
- };
7
4
  export interface TangleExactProcessOptions {
8
5
  teamId?: string;
9
6
  }
@@ -12,6 +9,17 @@ export interface SandboxClientLike {
12
9
  signal?: AbortSignal;
13
10
  timeoutMs?: number;
14
11
  }): Promise<SandboxInstanceLike>;
12
+ /**
13
+ * SDK HttpClient transport, present on `Sandbox` and `TangleSandboxClient`.
14
+ * Retained control requires it: the provider mints a lazy probe instance
15
+ * over this surface to read the linked SDK's method surface before any
16
+ * sandbox exists. An object-spread wrapper (`{ ...client }`) drops class
17
+ * prototype methods including this one, so such a wrapper never claims
18
+ * retained control; pass the SDK client itself or delegate its methods.
19
+ */
20
+ fetch?(path: string, options?: RequestInit, fetchOptions?: {
21
+ timeoutMs?: number;
22
+ }): Promise<Response>;
15
23
  get?(id: string, requestOptions?: {
16
24
  signal?: AbortSignal;
17
25
  }): Promise<SandboxInstanceLike | null>;
@@ -56,9 +64,9 @@ export interface SandboxInstanceLike {
56
64
  name?: string;
57
65
  status?: unknown;
58
66
  metadata?: Record<string, unknown>;
59
- streamPrompt(message: string | InputPart[], options?: TanglePromptOptions): AsyncIterable<SandboxEvent>;
60
- prompt?(message: string | InputPart[], options?: TanglePromptOptions): Promise<PromptResult>;
61
- dispatchPrompt?(message: string | InputPart[], options?: TanglePromptOptions): Promise<unknown>;
67
+ streamPrompt(message: string | InputPart[], options?: PromptOptions): AsyncIterable<SandboxEvent>;
68
+ prompt?(message: string | InputPart[], options?: PromptOptions): Promise<PromptResult>;
69
+ dispatchPrompt?(message: string | InputPart[], options?: PromptOptions): Promise<unknown>;
62
70
  session?(id: string, options?: {
63
71
  signal?: AbortSignal;
64
72
  }): SandboxSessionLike;
@@ -98,12 +106,6 @@ export interface SandboxInstanceLike {
98
106
  }): Promise<unknown>;
99
107
  };
100
108
  process?: SandboxProcessManagerLike;
101
- checkpoint?(options?: {
102
- signal?: AbortSignal;
103
- } & Record<string, unknown>): Promise<unknown>;
104
- fork?(checkpointId: string, options?: {
105
- signal?: AbortSignal;
106
- } & Record<string, unknown>): Promise<SandboxInstanceLike>;
107
109
  refresh?(options?: {
108
110
  signal?: AbortSignal;
109
111
  }): Promise<void>;
@@ -125,7 +127,7 @@ export interface SandboxSessionLike {
125
127
  executionId?: string;
126
128
  signal?: AbortSignal;
127
129
  }): Promise<PromptResult>;
128
- prompt(message: string | InputPart[], options?: TanglePromptOptions): Promise<PromptResult>;
130
+ prompt(message: string | InputPart[], options?: PromptOptions): Promise<PromptResult>;
129
131
  interrupt(options?: {
130
132
  executionId?: string;
131
133
  signal?: AbortSignal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,7 +70,7 @@
70
70
  "@tangle-network/agent-interface": "0.47.0"
71
71
  },
72
72
  "peerDependencies": {
73
- "@tangle-network/sandbox": ">=0.17.0 <1.0.0"
73
+ "@tangle-network/sandbox": ">=0.19.6 <1.0.0"
74
74
  },
75
75
  "peerDependenciesMeta": {
76
76
  "@tangle-network/sandbox": {
@@ -78,8 +78,9 @@
78
78
  }
79
79
  },
80
80
  "devDependencies": {
81
- "@tangle-network/agent-runtime": "0.132.6",
82
- "@tangle-network/sandbox": "0.19.4",
81
+ "@tangle-network/agent-eval": "0.145.3",
82
+ "@tangle-network/agent-runtime": "0.132.13",
83
+ "@tangle-network/sandbox": "0.21.1",
83
84
  "@types/node": "25.6.0",
84
85
  "typescript": "^6.0.3",
85
86
  "vitest": "^4.1.5",