@tangle-network/agent-provider-tangle 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
3
  Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
4
- The peer range is `>=0.34.6 <1.0.0`, and this package is developed and tested against 0.34.6.
4
+ The peer range is `>=0.34.6 <1.0.0`, and this package is developed and tested against 0.37.0.
5
5
  The floor is 0.34.6 because exact interactive attachment needs the host receiver, and workspace branching needs keyed snapshots, durable restores, inventory recovery, and cleanup.
6
6
  The provider fails closed when the configured backend or its catalog entry cannot be read.
7
7
  Newer SDKs may also provide `getBackend()` as a lookup over the same catalog.
@@ -119,6 +119,7 @@ Each deployment flag this adapter reads gates the claims it backs, and no flag i
119
119
  | `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
120
120
  | `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
121
121
  | `interactions.responseDedupe` | `interactions`, `environment.respondToInteraction`, `session.respondToInteraction` |
122
+ | `interactiveAgent.*` | `interactiveAgent`, exact control, and generation-fenced terminal input and resize |
122
123
 
123
124
  Detached dispatch carries the caller's exact reference and refuses a receipt that does not name the execution back, so it needs both `dispatch` flags and a session handle to reach the run through.
124
125
  `sessions.continue`, `retainedControl`, and `session.cancelRun` need every flag in the table, because the capability schema refuses a partial retained-control block and each identity rests on its own flag.
@@ -205,6 +206,11 @@ cannot prove the complete operation surface.
205
206
  Recovery reads the account inventory through Sandbox offset pages of at most
206
207
  1,000 sandboxes and continues until a short page proves the inventory is
207
208
  complete.
209
+ If a live snapshot loses its marker tags, recovery reads the owner-scoped
210
+ operation record by key and binds its stored result to that live snapshot id.
211
+ The operation record alone never revives a deleted snapshot.
212
+ An older Sandbox deployment that cannot answer the owner-scoped lookup returns
213
+ `unknown` and does not create or mutate a checkpoint.
208
214
  The adapter also reads checkpoints from the previous marker format while writing only the current bounded format.
209
215
  Malformed, repeated, failed, or over-bound pages return `unknown` and do not
210
216
  mutate a resource.
@@ -328,6 +334,11 @@ Every reference states an expiry one detach window past the newest activity the
328
334
  `close()` reports `closed` only when the socket delivered an exit; Sandbox exposes no terminal delete, so an unproven close reports `unknown` rather than claiming a termination that did not happen.
329
335
  An attach whose runtime metadata lacks a name, shell, working directory, geometry, timestamps, running state, or detach window fails closed with `unknown`, because a reference cannot state facts the runtime did not report.
330
336
 
337
+ The exact interactive adapter validates control before attach, prompt, and stop operations.
338
+ An attached terminal sends input and resize operations through its authenticated terminal connection.
339
+ The sidecar authorizes those mutations, so the provider does not add a second REST request before each operation.
340
+ Terminal close only detaches the caller's socket, so it stays available after control moves to a newer coordinator.
341
+
331
342
  Pass `exactProcess: {}` only when the Sandbox deployment supports `agent: false` creates and reports `metadata.runtimeMode: "control"`.
332
343
  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.
333
344
  Set `teamId` inside `exactProcess` to scope create, lookup, and recovery to one team.
@@ -186,7 +186,7 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
186
186
  await terminal.detach().catch(() => undefined);
187
187
  throw error;
188
188
  }
189
- return validatedTerminal(terminal, exactRequest.control, validateControl, ref);
189
+ return validatedTerminal(terminal, exactRequest.control, ref);
190
190
  },
191
191
  async sendPrompt(command, options) {
192
192
  assertOptionKeys(options, ["signal"], "Tangle interactive agent prompt");
@@ -246,7 +246,7 @@ function isSandboxInteractiveSession(value) {
246
246
  "stop",
247
247
  ].every((key) => typeof candidate[key] === "function");
248
248
  }
249
- function validatedTerminal(terminal, control, validate, expectedRef) {
249
+ function validatedTerminal(terminal, control, expectedRef) {
250
250
  const boundControl = Object.freeze({ ...control });
251
251
  return {
252
252
  get ref() {
@@ -261,19 +261,12 @@ function validatedTerminal(terminal, control, validate, expectedRef) {
261
261
  return TerminalReplayWindowSchema.parse(terminal.cursors);
262
262
  },
263
263
  control: boundControl,
264
- async input(input, options) {
265
- await validate(boundControl, options);
266
- await terminal.input(input, options);
267
- },
268
- async resize(resize, options) {
269
- await validate(boundControl, options);
270
- await terminal.resize(resize, options);
271
- },
264
+ // The authenticated terminal transport authorizes input and resize.
265
+ // A REST preflight adds latency and can race the authoritative operation.
266
+ input: (input, options) => terminal.input(input, options),
267
+ resize: (resize, options) => terminal.resize(resize, options),
272
268
  detach: (options) => terminal.detach(options),
273
- async close(options) {
274
- await validate(boundControl, options);
275
- return terminal.close(options);
276
- },
269
+ close: (options) => terminal.close(options),
277
270
  events: (options) => terminal.events(options),
278
271
  };
279
272
  }
@@ -57,8 +57,8 @@ export function createTangleWorkspaceBranching(options) {
57
57
  *
58
58
  * The in-process record answers first; otherwise the Sandbox inventory and
59
59
  * operation ledger rebuild it after a restart. `absent` means the key has no
60
- * resource this provider created, which is the only state where a caller may
61
- * go on to create one.
60
+ * record, which is the only state where a caller may go on to create one.
61
+ * `retired` means the durable operation settled but its snapshot is gone.
62
62
  */
63
63
  const resolveCheckpoint = async (request, signal) => {
64
64
  const local = checkpoints.get(request.idempotencyKey);
@@ -70,30 +70,20 @@ export function createTangleWorkspaceBranching(options) {
70
70
  existingRequestDigest: local.request.requestDigest,
71
71
  };
72
72
  }
73
- const recovered = await findCheckpointByKey(box, request.idempotencyKey, signal);
74
- if (recovered === undefined) {
73
+ const recovered = await reconcileCheckpoint(box, provider, request, signal);
74
+ if (recovered.state === "undecided") {
75
75
  return {
76
76
  state: "undecided",
77
- message: "Sandbox checkpoint inventory is unavailable",
77
+ message: recovered.reason === "inventory_unavailable"
78
+ ? "Sandbox checkpoint inventory is unavailable"
79
+ : "Sandbox checkpoint metadata is invalid",
78
80
  };
79
81
  }
80
- if (!recovered)
81
- return { state: "absent" };
82
- if (recovered.marker.requestDigest !== request.requestDigest) {
83
- return {
84
- state: "conflict",
85
- existingRequestDigest: recovered.marker.requestDigest,
86
- };
82
+ if (recovered.state === "found") {
83
+ checkpoints.set(request.idempotencyKey, recovered.record);
84
+ return { state: "known", record: recovered.record };
87
85
  }
88
- const record = checkpointRecordFromSnapshot(recovered.marker.request, recovered.snapshot);
89
- if (!record) {
90
- return {
91
- state: "undecided",
92
- message: "Sandbox checkpoint metadata is invalid",
93
- };
94
- }
95
- checkpoints.set(request.idempotencyKey, record);
96
- return { state: "known", record };
86
+ return recovered;
97
87
  };
98
88
  /** The fork equivalent of {@link resolveCheckpoint}. */
99
89
  const resolveFork = async (request, signal) => {
@@ -153,17 +143,21 @@ export function createTangleWorkspaceBranching(options) {
153
143
  if (known.state === "undecided") {
154
144
  return checkpointUnknown(request, `${known.message}; retry after reconciliation`, true);
155
145
  }
146
+ if (known.state === "retired") {
147
+ return checkpointUnknown(request, "Sandbox checkpoint operation is settled but its snapshot is no longer present; the idempotency key cannot be reused", false);
148
+ }
156
149
  if (known.state === "known") {
157
150
  return checkpointSuccess(request, known.record.checkpoint, "replayed");
158
151
  }
159
152
  const tags = checkpointMarkerTags(request);
160
153
  let result;
161
154
  try {
155
+ operation?.signal?.throwIfAborted();
162
156
  result = await awaitWithSignal(box.snapshot?.({ tags, idempotencyKey: request.idempotencyKey }), operation?.signal);
163
157
  }
164
158
  catch (error) {
165
159
  operation?.signal?.throwIfAborted();
166
- const conflict = await checkpointConflictFromRemote(box, request, operation?.signal);
160
+ const conflict = await checkpointConflictFromRemote(box, provider, request, operation?.signal);
167
161
  return (conflict ??
168
162
  checkpointUnknown(request, `Sandbox checkpoint outcome is unresolved: ${safeError(error)}`, true));
169
163
  }
@@ -180,6 +174,26 @@ export function createTangleWorkspaceBranching(options) {
180
174
  if (resultMarker && resultMarker.requestDigest !== request.requestDigest) {
181
175
  return checkpointConflict(request, resultMarker.requestDigest);
182
176
  }
177
+ if (resultMarker &&
178
+ !checkpointMarkerBelongsToSource(resultMarker, provider, box.id)) {
179
+ return checkpointUnknown(request, "Sandbox checkpoint acknowledgement belongs to another source", false);
180
+ }
181
+ if (result.idempotency.outcome === "replayed") {
182
+ const recovered = await reconcileCheckpoint(box, provider, request, operation?.signal);
183
+ if (recovered.state === "retired" || recovered.state === "absent") {
184
+ return checkpointUnknown(request, "Sandbox replay acknowledged a checkpoint that is no longer present", false);
185
+ }
186
+ if (recovered.state === "undecided") {
187
+ return checkpointUnknown(request, recovered.reason === "metadata_invalid"
188
+ ? "Sandbox replay returned invalid checkpoint metadata"
189
+ : "Sandbox replay could not be reconciled with live checkpoint inventory", true);
190
+ }
191
+ if (recovered.state === "conflict") {
192
+ return checkpointConflict(request, recovered.existingRequestDigest);
193
+ }
194
+ checkpoints.set(request.idempotencyKey, recovered.record);
195
+ return checkpointSuccess(request, recovered.record.checkpoint, "replayed");
196
+ }
183
197
  if (!resultMarker) {
184
198
  return checkpointUnknown(request, "Sandbox checkpoint acknowledgement omitted its provider recovery marker", true);
185
199
  }
@@ -199,20 +213,13 @@ export function createTangleWorkspaceBranching(options) {
199
213
  if (known.state === "undecided") {
200
214
  return checkpointLookupUnknown(request, known.message, true);
201
215
  }
216
+ if (known.state === "retired") {
217
+ return checkpointNotFound(request);
218
+ }
202
219
  if (known.state === "known") {
203
220
  return checkpointFound(request, known.record.checkpoint);
204
221
  }
205
- try {
206
- const lookup = await awaitWithSignal(box.getSnapshotOperation?.(request.idempotencyKey, { tags: [] }), operation?.signal);
207
- const settled = lookupOutcomeFromSandbox(lookup, "checkpoint");
208
- return settled.absent
209
- ? checkpointNotFound(request)
210
- : checkpointLookupUnknown(request, settled.message, settled.retryable);
211
- }
212
- catch (error) {
213
- operation?.signal?.throwIfAborted();
214
- return checkpointLookupUnknown(request, `Sandbox checkpoint lookup failed: ${safeError(error)}`, true);
215
- }
222
+ return checkpointNotFound(request);
216
223
  };
217
224
  const deleteCheckpoint = async (input, operation) => {
218
225
  const request = WorkspaceCleanupRequestSchema.parse(input);
@@ -731,6 +738,11 @@ function validSnapshotOperationResult(value) {
731
738
  safeIdentifier(value.snapshotId) !== undefined &&
732
739
  validOperationDate(value.createdAt));
733
740
  }
741
+ function validTaggedSnapshotOperationResult(value) {
742
+ return (validSnapshotOperationResult(value) &&
743
+ Array.isArray(value.tags) &&
744
+ value.tags.every((tag) => safeString(tag) !== undefined));
745
+ }
734
746
  function validForkOperationChildResult(value) {
735
747
  return (validOperationRecord(value) &&
736
748
  safeIdentifier(value.sandboxId ?? value.id) !== undefined &&
@@ -796,6 +808,7 @@ function forkMarkerMetadata(request, materialization = "snapshot") {
796
808
  * until the ledger reports the operation succeeded.
797
809
  */
798
810
  async function checkpointOperationLookup(box, marker, signal) {
811
+ signal?.throwIfAborted();
799
812
  const lookup = await awaitWithSignal(box.getSnapshotOperation?.(marker.idempotencyKey, {
800
813
  tags: marker.legacy
801
814
  ? legacyCheckpointMarkerTags(marker.request)
@@ -803,6 +816,11 @@ async function checkpointOperationLookup(box, marker, signal) {
803
816
  }), signal);
804
817
  return lookup;
805
818
  }
819
+ /** Read the durable record by its owner-scoped key when no request body remains. */
820
+ async function checkpointOperationLookupByKey(box, idempotencyKey, signal) {
821
+ signal?.throwIfAborted();
822
+ return await awaitWithSignal(box.getSnapshotOperation?.(idempotencyKey), signal);
823
+ }
806
824
  async function checkpointOperationSucceeded(box, marker, signal) {
807
825
  const lookup = await checkpointOperationLookup(box, marker, signal);
808
826
  return (lookup?.outcome === "found" &&
@@ -850,9 +868,10 @@ function markerTags(kind, idempotencyKey, requestDigest, marker) {
850
868
  return tag;
851
869
  });
852
870
  }
853
- async function findCheckpointByKey(box, key, signal) {
871
+ async function findCheckpointByKey(box, provider, key, signal) {
854
872
  let snapshots;
855
873
  try {
874
+ signal?.throwIfAborted();
856
875
  const listed = await awaitWithSignal(box.listSnapshots?.(), signal);
857
876
  if (!Array.isArray(listed))
858
877
  return undefined;
@@ -865,13 +884,21 @@ async function findCheckpointByKey(box, key, signal) {
865
884
  if (!Array.isArray(snapshots) || snapshots.length > MAX_LIST_RESULTS) {
866
885
  return undefined;
867
886
  }
887
+ const snapshotIds = new Set();
888
+ let found;
868
889
  let unresolved = false;
869
890
  for (const snapshot of snapshots) {
870
891
  if (!validSnapshotInfo(snapshot, box.id))
871
892
  return undefined;
893
+ if (snapshotIds.has(snapshot.snapshotId))
894
+ return undefined;
895
+ snapshotIds.add(snapshot.snapshotId);
872
896
  const marker = checkpointMarkerFromTags(snapshot.tags, key);
873
897
  if (!marker)
874
898
  continue;
899
+ if (!checkpointMarkerBelongsToSource(marker, provider, box.id)) {
900
+ return undefined;
901
+ }
875
902
  try {
876
903
  const lookup = await checkpointOperationLookup(box, marker, signal);
877
904
  if (lookup?.outcome === "found" &&
@@ -880,7 +907,10 @@ async function findCheckpointByKey(box, key, signal) {
880
907
  const authoritative = snapshotFromOperationResult(snapshot, lookup);
881
908
  if (authoritative === undefined)
882
909
  return undefined;
883
- return { snapshot: authoritative, marker };
910
+ if (found !== undefined)
911
+ return undefined;
912
+ found = { state: "found", snapshot: authoritative, marker };
913
+ continue;
884
914
  }
885
915
  unresolved = true;
886
916
  }
@@ -889,7 +919,69 @@ async function findCheckpointByKey(box, key, signal) {
889
919
  return undefined;
890
920
  }
891
921
  }
892
- return unresolved ? undefined : null;
922
+ if (found !== undefined)
923
+ return found;
924
+ if (unresolved)
925
+ return undefined;
926
+ // Some storage backends retain the snapshot but omit caller tags from a
927
+ // later inventory read. The owner-scoped operation record retains the exact
928
+ // acknowledgement, including those tags. Bind that record to a currently
929
+ // live snapshot id before recovering it; neither record is sufficient alone.
930
+ let lookup;
931
+ try {
932
+ lookup = await checkpointOperationLookupByKey(box, key, signal);
933
+ }
934
+ catch {
935
+ signal?.throwIfAborted();
936
+ return undefined;
937
+ }
938
+ if (lookup?.outcome === "not_found" && lookup.kind === "checkpoint") {
939
+ return null;
940
+ }
941
+ if (lookup?.outcome !== "found" ||
942
+ lookup.kind !== "checkpoint" ||
943
+ lookup.state !== "succeeded") {
944
+ return undefined;
945
+ }
946
+ if (snapshots.length === 0)
947
+ return { state: "retired" };
948
+ if (!validTaggedSnapshotOperationResult(lookup.result))
949
+ return undefined;
950
+ const live = snapshots.filter((snapshot) => snapshot.snapshotId === lookup.result?.snapshotId);
951
+ if (live.length === 0)
952
+ return { state: "retired" };
953
+ if (live.length !== 1)
954
+ return undefined;
955
+ const marker = checkpointMarkerFromTags(lookup.result.tags, key);
956
+ if (!marker ||
957
+ !checkpointMarkerBelongsToSource(marker, provider, box.id)) {
958
+ return undefined;
959
+ }
960
+ const authoritative = snapshotFromOperationResult(live[0], lookup);
961
+ return authoritative === undefined
962
+ ? undefined
963
+ : { state: "found", snapshot: authoritative, marker };
964
+ }
965
+ /** Normalize one remote checkpoint recovery attempt for every caller. */
966
+ async function reconcileCheckpoint(box, provider, request, signal) {
967
+ const recovered = await findCheckpointByKey(box, provider, request.idempotencyKey, signal);
968
+ if (recovered === undefined) {
969
+ return { state: "undecided", reason: "inventory_unavailable" };
970
+ }
971
+ if (recovered === null)
972
+ return { state: "absent" };
973
+ if (recovered.state === "retired")
974
+ return recovered;
975
+ if (recovered.marker.requestDigest !== request.requestDigest) {
976
+ return {
977
+ state: "conflict",
978
+ existingRequestDigest: recovered.marker.requestDigest,
979
+ };
980
+ }
981
+ const record = checkpointRecordFromSnapshot(recovered.marker.request, recovered.snapshot);
982
+ return record === undefined
983
+ ? { state: "undecided", reason: "metadata_invalid" }
984
+ : { state: "found", record };
893
985
  }
894
986
  /**
895
987
  * Prefer the durable operation result over inventory metadata.
@@ -1177,6 +1269,10 @@ function markerBelongsToSource(marker, provider, sourceEnvironmentId) {
1177
1269
  return (marker.request.checkpoint.provider === provider &&
1178
1270
  marker.request.checkpoint.source.environmentId === sourceEnvironmentId);
1179
1271
  }
1272
+ function checkpointMarkerBelongsToSource(marker, provider, sourceEnvironmentId) {
1273
+ return (marker.request.source.provider === provider &&
1274
+ marker.request.source.environmentId === sourceEnvironmentId);
1275
+ }
1180
1276
  function checkpointMarkerFromTags(tags, key) {
1181
1277
  if (!Array.isArray(tags) ||
1182
1278
  tags.length > MAX_MARKER_CHUNKS + 3 ||
@@ -1324,19 +1420,19 @@ function forkMarkerFromMetadata(metadata, key) {
1324
1420
  * digest covers the Sandbox request body, not this interface's identity, so it
1325
1421
  * is never presented as an interface digest.
1326
1422
  */
1327
- async function checkpointConflictFromRemote(box, request, signal) {
1423
+ async function checkpointConflictFromRemote(box, provider, request, signal) {
1328
1424
  signal?.throwIfAborted();
1329
- const recovered = await findCheckpointByKey(box, request.idempotencyKey, signal);
1425
+ const recovered = await reconcileCheckpoint(box, provider, request, signal);
1330
1426
  signal?.throwIfAborted();
1331
- if (!recovered)
1332
- return undefined;
1333
- if (recovered.marker.requestDigest !== request.requestDigest) {
1334
- return checkpointConflict(request, recovered.marker.requestDigest);
1427
+ if (recovered.state === "retired") {
1428
+ return checkpointUnknown(request, "Sandbox checkpoint operation is settled but its snapshot is no longer present; the idempotency key cannot be reused", false);
1335
1429
  }
1336
- const record = checkpointRecordFromSnapshot(recovered.marker.request, recovered.snapshot);
1337
- return record === undefined
1430
+ if (recovered.state === "conflict") {
1431
+ return checkpointConflict(request, recovered.existingRequestDigest);
1432
+ }
1433
+ return recovered.state !== "found"
1338
1434
  ? undefined
1339
- : checkpointSuccess(request, record.checkpoint, "replayed");
1435
+ : checkpointSuccess(request, recovered.record.checkpoint, "replayed");
1340
1436
  }
1341
1437
  /** The fork equivalent of {@link checkpointConflictFromRemote}. */
1342
1438
  async function forkConflictFromRemote(client, box, provider, request, verifier, signal) {
@@ -1355,10 +1451,10 @@ async function forkConflictFromRemote(client, box, provider, request, verifier,
1355
1451
  : forkSuccess(request, environment, "replayed");
1356
1452
  }
1357
1453
  /**
1358
- * Read a ledger answer for a key that left no marked resource behind.
1454
+ * Read a fork ledger answer for a key that left no marked resource behind.
1359
1455
  *
1360
1456
  * `absent` is the settled answer: a decided operation with no inventory marker
1361
- * means the resource was cleaned after creation, and the provider must not
1457
+ * means the child was cleaned after creation, and the provider must not
1362
1458
  * resurrect it from the ledger. Every other state is undecided for the caller.
1363
1459
  */
1364
1460
  function lookupOutcomeFromSandbox(lookup, kind) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -102,13 +102,13 @@
102
102
  }
103
103
  },
104
104
  "devDependencies": {
105
- "@tangle-network/agent-eval": "0.170.0",
106
- "@tangle-network/agent-runtime": "0.184.0",
107
- "@tangle-network/sandbox": "0.34.6",
108
- "@types/node": "26.4.0",
105
+ "@tangle-network/agent-eval": "0.173.1",
106
+ "@tangle-network/agent-runtime": "0.192.2",
107
+ "@tangle-network/sandbox": "0.37.0",
108
+ "@types/node": "26.4.1",
109
109
  "typescript": "7.0.2",
110
110
  "vitest": "4.1.11",
111
- "@tangle-network/agent-provider-testkit": "0.9.0"
111
+ "@tangle-network/agent-provider-testkit": "0.9.2"
112
112
  },
113
113
  "scripts": {
114
114
  "build": "tsc -p tsconfig.json",