@tangle-network/agent-provider-tangle 0.4.6 → 0.4.7

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,6 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
- Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
3
+ Wraps `@tangle-network/sandbox` 0.17 or newer as an `AgentEnvironmentProvider`.
4
4
 
5
5
  ```ts
6
6
  import { Sandbox } from '@tangle-network/sandbox'
@@ -11,6 +11,17 @@ const provider = createTangleProvider({
11
11
  })
12
12
  ```
13
13
 
14
+ Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`.
15
+ The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled.
16
+ Reconstruct an exact session with `environment.session(reference.id, { controlRef: reference.controlRef })`; replay cursors are exclusive at the agent interface even though the Sandbox stream is inclusive.
17
+ Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session.
18
+ 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.
19
+ 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.
20
+ 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.
21
+ It also rejects `contextTransfer` and `nativeContinuation` inputs explicitly until those operations have native Sandbox support instead of silently dropping them.
22
+ The default adapter does not advertise legacy checkpoint or fork operations because Sandbox 0.17 exposes snapshots and branches with different semantics.
23
+ 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.
24
+
14
25
  Pass `exactProcess: {}` only when the Sandbox deployment supports `agent: false` creates and reports `metadata.runtimeMode: "control"`.
15
26
  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.
16
27
  Set `teamId` inside `exactProcess` to scope create, lookup, and recovery to one team.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BackendType, CreateSandboxOptions, ExecResult as SandboxExecResult, PromptOptions, PromptResult, SandboxEvent } from "@tangle-network/sandbox";
2
- import type { AgentEnvironmentCapabilities, AgentEnvironmentProvider, CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
2
+ import { type AgentEnvironmentCapabilities, type AgentEnvironmentProvider, type CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
3
3
  import type { InputPart } from "@tangle-network/agent-interface";
4
4
  import { type TangleExactProcessOptions } from "./exact-process.js";
5
5
  export type { TangleExactProcessOptions } from "./exact-process.js";
@@ -93,11 +93,16 @@ export interface SandboxSessionLike {
93
93
  status(): Promise<unknown | null>;
94
94
  events(options?: {
95
95
  since?: string;
96
+ executionId?: string;
96
97
  signal?: AbortSignal;
97
98
  }): AsyncIterable<SandboxEvent>;
98
- result(): Promise<PromptResult>;
99
+ result(options?: {
100
+ executionId?: string;
101
+ }): Promise<PromptResult>;
99
102
  prompt(message: string | InputPart[], options?: PromptOptions): Promise<PromptResult>;
100
- interrupt(): Promise<{
103
+ interrupt(options?: {
104
+ executionId?: string;
105
+ }): Promise<{
101
106
  cancelled: boolean;
102
107
  }>;
103
108
  }
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { AgentEnvironmentCapabilitiesSchema, } from "@tangle-network/agent-interface/environment-provider";
3
+ import { AgentRunControlRefSchema, ContextTransferReceiptSchema, } from "@tangle-network/agent-interface";
1
4
  import { createTangleExactProcessProvider, } from "./exact-process.js";
2
5
  export function createTangleProvider(options) {
3
6
  const providerName = options.name ?? "tangle-sandbox";
@@ -8,34 +11,41 @@ export function createTangleProvider(options) {
8
11
  providerName,
9
12
  })
10
13
  : undefined;
14
+ const resolveCapabilities = async () => {
15
+ const configured = options.capabilities
16
+ ? typeof options.capabilities === "function"
17
+ ? await options.capabilities()
18
+ : options.capabilities
19
+ : defaultTangleSandboxCapabilities();
20
+ if (!exactProcess && configured.exactProcess) {
21
+ throw new Error("Tangle capabilities cannot advertise exactProcess without exactProcess configuration");
22
+ }
23
+ return AgentEnvironmentCapabilitiesSchema.parse(exactProcess
24
+ ? {
25
+ ...configured,
26
+ exactProcess: { egress: ["blocked", "strict"] },
27
+ }
28
+ : configured);
29
+ };
11
30
  return {
12
31
  name: providerName,
13
32
  ...(exactProcess ? { exactProcess } : {}),
14
- capabilities: async () => {
15
- const capabilities = options.capabilities
16
- ? typeof options.capabilities === "function"
17
- ? await options.capabilities()
18
- : options.capabilities
19
- : defaultTangleSandboxCapabilities();
20
- if (!exactProcess && capabilities.exactProcess) {
21
- throw new Error("Tangle capabilities cannot advertise exactProcess without exactProcess configuration");
22
- }
23
- return exactProcess
24
- ? { ...capabilities, exactProcess: { egress: ["blocked", "strict"] } }
25
- : capabilities;
26
- },
33
+ capabilities: resolveCapabilities,
27
34
  ...(options.validateProfile ? { validateProfile: options.validateProfile } : {}),
28
35
  async create(input) {
36
+ const capabilities = await resolveCapabilities();
29
37
  const createOptions = options.mapCreateInput?.(input) ??
30
38
  sandboxOptionsFromCreateInput(input, options.defaultBackend ?? "opencode");
31
39
  const box = await options.client.create(createOptions, input.signal ? { signal: input.signal } : undefined);
32
- return sandboxInstanceAsEnvironment(box, providerName, options.client);
40
+ return sandboxInstanceAsEnvironment(box, providerName, options.client, capabilities);
33
41
  },
34
42
  ...(options.client.get
35
43
  ? {
36
44
  async get(id) {
37
45
  const box = await options.client.get?.(id);
38
- return box ? sandboxInstanceAsEnvironment(box, providerName, options.client) : null;
46
+ return box
47
+ ? sandboxInstanceAsEnvironment(box, providerName, options.client, await resolveCapabilities())
48
+ : null;
39
49
  },
40
50
  }
41
51
  : {}),
@@ -55,7 +65,7 @@ export function createTangleProvider(options) {
55
65
  : {}),
56
66
  };
57
67
  }
58
- function sandboxInstanceAsEnvironment(box, providerName, client) {
68
+ function sandboxInstanceAsEnvironment(box, providerName, client, capabilities) {
59
69
  return {
60
70
  id: String(box.id),
61
71
  provider: providerName,
@@ -65,44 +75,60 @@ function sandboxInstanceAsEnvironment(box, providerName, client) {
65
75
  return statusFromUnknown(box.status);
66
76
  },
67
77
  async *stream(input) {
68
- for await (const event of box.streamPrompt(promptFromTurnInput(input), promptOptionsFromTurnInput(input))) {
69
- yield environmentEventFromSandboxEvent(event);
78
+ const expectedExecutionId = executionIdFromTurnInput(input);
79
+ const expectedSessionId = input.sessionId ?? input.controlRef?.sessionId;
80
+ for await (const event of box.streamPrompt(promptFromTurnInput(input), promptOptionsFromTurnInput(input, {
81
+ provider: providerName,
82
+ environmentId: String(box.id),
83
+ }))) {
84
+ yield environmentEventFromSandboxEvent(event, {
85
+ executionId: expectedExecutionId,
86
+ sessionId: expectedSessionId,
87
+ });
70
88
  }
71
89
  },
72
- ...(box.dispatchPrompt
90
+ ...(capabilities.streaming.detach && box.dispatchPrompt
73
91
  ? {
74
92
  async dispatch(input) {
75
- const dispatched = await box.dispatchPrompt?.(promptFromTurnInput(input), promptOptionsFromTurnInput(input));
76
- return sessionRefFromSandboxDispatch(dispatched, providerName);
93
+ const dispatched = await box.dispatchPrompt?.(promptFromTurnInput(input), promptOptionsFromTurnInput(input, {
94
+ provider: providerName,
95
+ environmentId: String(box.id),
96
+ }));
97
+ return sessionRefFromSandboxDispatch(dispatched, providerName, String(box.id), executionIdFromTurnInput(input));
77
98
  },
78
99
  }
79
100
  : {}),
80
- ...(box.session
101
+ ...((capabilities.sessions.continue ||
102
+ capabilities.streaming.replay ||
103
+ capabilities.streaming.detach) &&
104
+ box.session
81
105
  ? {
82
- session(id) {
106
+ session(id, options) {
83
107
  const session = box.session?.(id);
84
108
  if (!session)
85
109
  throw new Error("sandbox session(id) returned undefined");
86
- return sandboxSessionAsAgentSession(session);
110
+ return sandboxSessionAsAgentSession(session, resolveRetainedSessionControlRef(options?.controlRef, session.id, providerName, String(box.id)), providerName, String(box.id));
87
111
  },
88
112
  }
89
113
  : {}),
90
- ...(box.read ? { read: box.read.bind(box) } : {}),
91
- ...(box.write
114
+ ...(capabilities.workspace.read && box.read
115
+ ? { read: box.read.bind(box) }
116
+ : {}),
117
+ ...(capabilities.workspace.write && box.write
92
118
  ? {
93
119
  async write(path, content) {
94
120
  await box.write?.(path, content);
95
121
  },
96
122
  }
97
123
  : {}),
98
- ...(box.exec
124
+ ...(capabilities.workspace.exec && box.exec
99
125
  ? {
100
126
  async exec(command, options) {
101
127
  return execResultFromSandboxExecResult(await box.exec?.(command, options));
102
128
  },
103
129
  }
104
130
  : {}),
105
- ...(box.checkpoint
131
+ ...(capabilities.branching.checkpoint && box.checkpoint
106
132
  ? {
107
133
  async checkpoint(options) {
108
134
  const result = await box.checkpoint?.(options);
@@ -110,19 +136,23 @@ function sandboxInstanceAsEnvironment(box, providerName, client) {
110
136
  },
111
137
  }
112
138
  : {}),
113
- ...(box.fork
139
+ ...(capabilities.branching.fork && box.fork
114
140
  ? {
115
141
  async fork(checkpoint, options) {
116
142
  const forked = await box.fork?.(checkpoint.id, options);
117
143
  if (!forked)
118
144
  throw new Error("sandbox fork returned no environment");
119
- return sandboxInstanceAsEnvironment(forked, providerName, client);
145
+ return sandboxInstanceAsEnvironment(forked, providerName, client, capabilities);
146
+ },
147
+ }
148
+ : {}),
149
+ ...(capabilities.placement
150
+ ? {
151
+ async placement() {
152
+ return placementInfoFromLoopPlacement(client.describePlacement?.(box), box);
120
153
  },
121
154
  }
122
155
  : {}),
123
- async placement() {
124
- return placementInfoFromLoopPlacement(client.describePlacement?.(box), box);
125
- },
126
156
  async refresh() {
127
157
  await box.refresh?.();
128
158
  },
@@ -131,9 +161,13 @@ function sandboxInstanceAsEnvironment(box, providerName, client) {
131
161
  },
132
162
  };
133
163
  }
134
- function sandboxSessionAsAgentSession(session) {
164
+ function sandboxSessionAsAgentSession(session, controlRef, provider, environmentId) {
165
+ let activeControlRef = controlRef;
135
166
  return {
136
167
  id: session.id,
168
+ get controlRef() {
169
+ return activeControlRef;
170
+ },
137
171
  async status() {
138
172
  const status = await session.status();
139
173
  if (!status)
@@ -141,17 +175,105 @@ function sandboxSessionAsAgentSession(session) {
141
175
  return sessionStatusFromUnknown(status.status);
142
176
  },
143
177
  async *events(options) {
144
- for await (const event of session.events(options))
145
- yield environmentEventFromSandboxEvent(event);
178
+ if (options?.executionId !== undefined &&
179
+ activeControlRef?.executionId !== undefined &&
180
+ options.executionId !== activeControlRef.executionId) {
181
+ throw new Error("Tangle replay executionId conflicts with the control reference");
182
+ }
183
+ const executionId = activeControlRef?.executionId ?? options?.executionId;
184
+ if (options?.since !== undefined && executionId === undefined) {
185
+ throw new Error("Tangle cursor replay requires an exact executionId from its control reference");
186
+ }
187
+ const seenEventIds = new Set();
188
+ for await (const event of session.events({
189
+ ...(options?.since !== undefined ? { since: options.since } : {}),
190
+ ...(executionId !== undefined ? { executionId } : {}),
191
+ ...(options?.signal ? { signal: options.signal } : {}),
192
+ })) {
193
+ if (options?.since !== undefined && event.id === options.since)
194
+ continue;
195
+ const converted = environmentEventFromSandboxEvent(event, {
196
+ executionId,
197
+ sessionId: session.id,
198
+ });
199
+ if (executionId !== undefined && converted.id === undefined) {
200
+ throw new Error("Tangle exact session replay received an event without a stable id");
201
+ }
202
+ if (converted.id !== undefined) {
203
+ if (seenEventIds.has(converted.id)) {
204
+ throw new Error(`Tangle session replay repeated event id ${converted.id}`);
205
+ }
206
+ seenEventIds.add(converted.id);
207
+ }
208
+ yield converted;
209
+ }
146
210
  },
147
211
  async result() {
148
- return agentTurnResultFromPromptResult(await session.result());
212
+ const expectedExecutionId = activeControlRef?.executionId;
213
+ if (expectedExecutionId === undefined) {
214
+ throw new Error("Tangle session result requires an exact executionId from its control reference");
215
+ }
216
+ const result = await session.result({ executionId: expectedExecutionId });
217
+ const resultRecord = validatedSandboxPromptResult(result);
218
+ if (resultRecord.executionId !== expectedExecutionId) {
219
+ throw new Error("Tangle session result did not confirm its exact executionId");
220
+ }
221
+ return agentTurnResultFromPromptRecord(resultRecord);
149
222
  },
150
223
  async prompt(input) {
151
- return agentTurnResultFromPromptResult(await session.prompt(promptFromTurnInput(input), promptOptionsFromTurnInput(input)));
224
+ if (input.sessionId !== undefined && input.sessionId !== session.id) {
225
+ throw new Error("Tangle sessionId conflicts with this session");
226
+ }
227
+ const requestedControlRef = resolveRetainedSessionControlRef(input.controlRef, session.id, provider, environmentId);
228
+ if (activeControlRef !== undefined &&
229
+ requestedControlRef !== undefined &&
230
+ !sameRunControlRef(activeControlRef, requestedControlRef)) {
231
+ throw new Error("Tangle prompt control reference conflicts with this session");
232
+ }
233
+ const sourceControlRef = requestedControlRef ?? activeControlRef;
234
+ const replay = input.lastEventId !== undefined;
235
+ if (replay &&
236
+ sourceControlRef?.executionId !== undefined &&
237
+ input.executionId !== undefined &&
238
+ input.executionId !== sourceControlRef.executionId) {
239
+ throw new Error("Tangle replay executionId conflicts with the control reference");
240
+ }
241
+ const executionId = replay
242
+ ? input.executionId ?? sourceControlRef?.executionId
243
+ : input.executionId ??
244
+ sessionPromptExecutionId(provider, environmentId, session.id, input.turnId);
245
+ if (executionId === undefined) {
246
+ throw new Error("Tangle session replay requires the exact executionId from its control reference");
247
+ }
248
+ const result = await session.prompt(promptFromTurnInput(input), promptOptionsFromTurnInput({
249
+ ...input,
250
+ sessionId: session.id,
251
+ executionId,
252
+ controlRef: undefined,
253
+ }, {
254
+ provider,
255
+ environmentId,
256
+ sessionId: session.id,
257
+ }));
258
+ const resultRecord = validatedSandboxPromptResult(result);
259
+ if (resultRecord.executionId !== executionId) {
260
+ throw new Error("Tangle session prompt did not confirm its exact executionId");
261
+ }
262
+ if (replay) {
263
+ activeControlRef =
264
+ sourceControlRef ??
265
+ retainedSessionControlRef(session.id, executionId, provider, environmentId);
266
+ return agentTurnResultFromPromptRecord(resultRecord);
267
+ }
268
+ activeControlRef = retainedSessionControlRef(session.id, executionId, provider, environmentId);
269
+ return agentTurnResultFromPromptRecord(resultRecord);
152
270
  },
153
271
  async cancel() {
154
- await session.interrupt();
272
+ const executionId = activeControlRef?.executionId;
273
+ if (executionId === undefined) {
274
+ throw new Error("Tangle session cancellation requires an exact executionId from its control reference");
275
+ }
276
+ await session.interrupt({ executionId });
155
277
  },
156
278
  };
157
279
  }
@@ -188,14 +310,42 @@ function inlineAgentProfile(profile) {
188
310
  }
189
311
  return profile;
190
312
  }
191
- function environmentEventFromSandboxEvent(event) {
192
- const data = event.data && typeof event.data === "object"
193
- ? event.data
194
- : {};
313
+ function environmentEventFromSandboxEvent(event, expected = {}) {
314
+ if (!event || typeof event !== "object") {
315
+ throw new Error("Tangle Sandbox emitted a non-object event");
316
+ }
317
+ const record = event;
318
+ if (typeof record.type !== "string" || record.type.length === 0) {
319
+ throw new Error("Tangle Sandbox event omitted its type");
320
+ }
321
+ if (!record.data ||
322
+ typeof record.data !== "object" ||
323
+ Array.isArray(record.data)) {
324
+ throw new Error("Tangle Sandbox event omitted its object data");
325
+ }
326
+ if (record.id !== undefined &&
327
+ (typeof record.id !== "string" || record.id.length === 0)) {
328
+ throw new Error("Tangle Sandbox event contained an invalid event id");
329
+ }
330
+ const data = record.data;
331
+ const eventExecutionId = optionalNonEmptyString(data.executionId, "Tangle Sandbox event executionId");
332
+ const eventSessionId = optionalNonEmptyString(data.sessionId, "Tangle Sandbox event sessionId");
333
+ // Sandbox binds the stream with session.events({ executionId }). Individual
334
+ // event variants do not all repeat that selector, so validate IDs when present.
335
+ if (expected.executionId !== undefined &&
336
+ eventExecutionId !== undefined &&
337
+ eventExecutionId !== expected.executionId) {
338
+ throw new Error("Tangle exact session event identified a different executionId");
339
+ }
340
+ if (expected.sessionId !== undefined &&
341
+ eventSessionId !== undefined &&
342
+ eventSessionId !== expected.sessionId) {
343
+ throw new Error("Tangle exact session event identified a different sessionId");
344
+ }
195
345
  return {
196
- type: String(event.type),
346
+ type: record.type,
197
347
  data,
198
- ...(event.id ? { id: event.id } : {}),
348
+ ...(typeof record.id === "string" ? { id: record.id } : {}),
199
349
  usage: tokenUsageFromData(data),
200
350
  providerEvent: event,
201
351
  };
@@ -205,21 +355,102 @@ function promptFromTurnInput(input) {
205
355
  return input.parts;
206
356
  return input.prompt ?? "";
207
357
  }
208
- function promptOptionsFromTurnInput(input) {
358
+ function executionIdFromTurnInput(input) {
359
+ return input.executionId ?? input.controlRef?.executionId;
360
+ }
361
+ function promptOptionsFromTurnInput(input, target) {
362
+ if (input.contextTransfer !== undefined) {
363
+ throw new Error("Tangle provider does not yet support portable context transfer");
364
+ }
365
+ if (input.nativeContinuation !== undefined) {
366
+ throw new Error("Tangle provider does not yet support verified native continuation");
367
+ }
368
+ const controlRef = input.controlRef
369
+ ? AgentRunControlRefSchema.parse(input.controlRef)
370
+ : undefined;
371
+ if (controlRef) {
372
+ if (controlRef.provider !== target.provider ||
373
+ controlRef.environmentId !== target.environmentId ||
374
+ (target.sessionId !== undefined &&
375
+ controlRef.sessionId !== target.sessionId)) {
376
+ throw new Error("Tangle control reference does not match this target");
377
+ }
378
+ if (controlRef.sessionId === undefined || controlRef.executionId === undefined) {
379
+ throw new Error("Tangle control reference requires exact sessionId and executionId");
380
+ }
381
+ if (controlRef.runId !== controlRef.executionId) {
382
+ throw new Error("Tangle control reference requires runId to equal executionId");
383
+ }
384
+ if (input.sessionId !== undefined &&
385
+ input.sessionId !== controlRef.sessionId) {
386
+ throw new Error("Tangle sessionId conflicts with the control reference");
387
+ }
388
+ if (input.executionId !== undefined &&
389
+ input.executionId !== controlRef.executionId) {
390
+ throw new Error("Tangle executionId conflicts with the control reference");
391
+ }
392
+ }
393
+ const sessionId = input.sessionId ?? controlRef?.sessionId;
394
+ const executionId = input.executionId ?? controlRef?.executionId;
209
395
  return {
210
- ...(input.sessionId ? { sessionId: input.sessionId } : {}),
396
+ ...(sessionId ? { sessionId } : {}),
211
397
  ...(input.model ? { model: input.model } : {}),
212
- ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
398
+ ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
213
399
  ...(input.context ? { context: input.context } : {}),
214
400
  ...(input.signal ? { signal: input.signal } : {}),
215
- ...(input.executionId ? { executionId: input.executionId } : {}),
401
+ ...(executionId ? { executionId } : {}),
216
402
  ...(input.lastEventId ? { lastEventId: input.lastEventId } : {}),
217
403
  ...(input.turnId ? { turnId: input.turnId } : {}),
218
404
  ...(input.detach !== undefined ? { detach: input.detach } : {}),
219
405
  };
220
406
  }
221
- function agentTurnResultFromPromptResult(result) {
407
+ function validatedSandboxPromptResult(result) {
408
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
409
+ throw new Error("Tangle prompt returned no result object");
410
+ }
222
411
  const record = result;
412
+ if (typeof record.success !== "boolean") {
413
+ throw new Error("Tangle prompt result omitted its success status");
414
+ }
415
+ const statuses = new Set([
416
+ "success",
417
+ "failed",
418
+ "blocked_on_approval",
419
+ "awaiting_question",
420
+ "awaiting_plan_decision",
421
+ ]);
422
+ if (typeof record.status !== "string" ||
423
+ !statuses.has(record.status)) {
424
+ throw new Error("Tangle prompt result contained an invalid run status");
425
+ }
426
+ if (record.success !== (record.status === "success")) {
427
+ throw new Error("Tangle prompt result success flag conflicts with its run status");
428
+ }
429
+ if (typeof record.durationMs !== "number" ||
430
+ !Number.isFinite(record.durationMs) ||
431
+ record.durationMs < 0) {
432
+ throw new Error("Tangle prompt result contained an invalid duration");
433
+ }
434
+ for (const field of [
435
+ "executionId",
436
+ "response",
437
+ "text",
438
+ "finalText",
439
+ "error",
440
+ "errorCode",
441
+ "traceId",
442
+ ]) {
443
+ if (record[field] !== undefined && typeof record[field] !== "string") {
444
+ throw new Error(`Tangle prompt result contained an invalid ${field}`);
445
+ }
446
+ }
447
+ if (record.executionId === "") {
448
+ throw new Error("Tangle prompt result contained an empty executionId");
449
+ }
450
+ tokenUsageFromData(record);
451
+ return record;
452
+ }
453
+ function agentTurnResultFromPromptRecord(record) {
223
454
  const text = typeof record.response === "string"
224
455
  ? record.response
225
456
  : typeof record.text === "string"
@@ -227,15 +458,22 @@ function agentTurnResultFromPromptResult(result) {
227
458
  : typeof record.finalText === "string"
228
459
  ? record.finalText
229
460
  : "";
230
- const success = typeof record.success === "boolean" ? record.success : true;
461
+ const contextTransferReceipt = ContextTransferReceiptSchema.safeParse(record.contextTransferReceipt);
462
+ if (record.contextTransferReceipt !== undefined &&
463
+ !contextTransferReceipt.success) {
464
+ throw new Error("Tangle prompt result contained an invalid context receipt");
465
+ }
231
466
  return {
232
467
  text,
233
- success,
468
+ success: record.success,
234
469
  ...(typeof record.error === "string" ? { error: record.error } : {}),
235
470
  usage: tokenUsageFromData(record),
471
+ ...(contextTransferReceipt.success
472
+ ? { contextTransferReceipt: contextTransferReceipt.data }
473
+ : {}),
236
474
  };
237
475
  }
238
- function sessionRefFromSandboxDispatch(dispatched, providerName) {
476
+ function sessionRefFromSandboxDispatch(dispatched, providerName, environmentId, expectedExecutionId) {
239
477
  const record = dispatched && typeof dispatched === "object"
240
478
  ? dispatched
241
479
  : undefined;
@@ -243,21 +481,91 @@ function sessionRefFromSandboxDispatch(dispatched, providerName) {
243
481
  if (typeof id !== "string" || id.length === 0 || !record) {
244
482
  throw new Error("sandbox dispatch returned no session id");
245
483
  }
484
+ const executionId = nonEmptyString(record.executionId);
485
+ if (executionId === undefined) {
486
+ throw new Error("sandbox dispatch returned no exact execution id for durable replay");
487
+ }
488
+ if (expectedExecutionId !== undefined &&
489
+ executionId !== expectedExecutionId) {
490
+ throw new Error("sandbox dispatch returned an execution id different from the requested run");
491
+ }
246
492
  return {
247
493
  id,
248
494
  provider: providerName,
495
+ controlRef: retainedSessionControlRef(id, executionId, providerName, environmentId),
249
496
  metadata: {
250
497
  ...(record.status ? { status: record.status } : {}),
251
498
  ...(record.alreadyExisted !== undefined ? { alreadyExisted: record.alreadyExisted } : {}),
499
+ ...(record.dispatched !== undefined ? { dispatched: record.dispatched } : {}),
252
500
  },
253
501
  };
254
502
  }
503
+ function retainedSessionControlRef(sessionId, executionId, provider, environmentId) {
504
+ return AgentRunControlRefSchema.parse({
505
+ runId: executionId,
506
+ provider,
507
+ environmentId,
508
+ sessionId,
509
+ executionId,
510
+ });
511
+ }
512
+ function sessionPromptExecutionId(provider, environmentId, sessionId, turnId) {
513
+ if (turnId === undefined)
514
+ return randomUUID();
515
+ const digest = createHash("sha256")
516
+ .update(`${provider}\0${environmentId}\0${sessionId}\0${turnId}`)
517
+ .digest("hex");
518
+ return `session-turn-${digest}`;
519
+ }
520
+ function sameRunControlRef(left, right) {
521
+ return (left.runId === right.runId &&
522
+ left.provider === right.provider &&
523
+ left.environmentId === right.environmentId &&
524
+ left.sessionId === right.sessionId &&
525
+ left.executionId === right.executionId);
526
+ }
527
+ function resolveRetainedSessionControlRef(candidate, sessionId, provider, environmentId) {
528
+ if (candidate === undefined)
529
+ return undefined;
530
+ const controlRef = AgentRunControlRefSchema.parse(candidate);
531
+ if (controlRef.provider !== provider ||
532
+ controlRef.environmentId !== environmentId ||
533
+ controlRef.sessionId !== sessionId) {
534
+ throw new Error("Tangle control reference does not match this session");
535
+ }
536
+ if (controlRef.executionId === undefined ||
537
+ controlRef.runId !== controlRef.executionId) {
538
+ throw new Error("Tangle session control reference requires runId to equal executionId");
539
+ }
540
+ return controlRef;
541
+ }
542
+ function nonEmptyString(value) {
543
+ return typeof value === "string" && value.length > 0 ? value : undefined;
544
+ }
545
+ function optionalNonEmptyString(value, label) {
546
+ if (value === undefined)
547
+ return undefined;
548
+ if (typeof value !== "string" || value.length === 0) {
549
+ throw new Error(`${label} must be a non-empty string`);
550
+ }
551
+ return value;
552
+ }
255
553
  function execResultFromSandboxExecResult(result) {
256
- const record = (result ?? {});
554
+ if (!result || typeof result !== "object") {
555
+ throw new Error("Tangle Sandbox exec returned no result");
556
+ }
557
+ const record = result;
558
+ if (typeof record.exitCode !== "number" ||
559
+ !Number.isSafeInteger(record.exitCode)) {
560
+ throw new Error("Tangle Sandbox exec returned an invalid exit code");
561
+ }
562
+ if (typeof record.stdout !== "string" || typeof record.stderr !== "string") {
563
+ throw new Error("Tangle Sandbox exec returned invalid output streams");
564
+ }
257
565
  return {
258
- exitCode: finiteNumber(record.exitCode) ?? finiteNumber(record.code) ?? 0,
259
- stdout: typeof record.stdout === "string" ? record.stdout : "",
260
- stderr: typeof record.stderr === "string" ? record.stderr : "",
566
+ exitCode: record.exitCode,
567
+ stdout: record.stdout,
568
+ stderr: record.stderr,
261
569
  };
262
570
  }
263
571
  function checkpointIdFromResult(result) {
@@ -280,22 +588,26 @@ function placementInfoFromLoopPlacement(placement, box) {
280
588
  };
281
589
  }
282
590
  function tokenUsageFromData(data) {
591
+ if (data.usage !== undefined &&
592
+ (!data.usage || typeof data.usage !== "object" || Array.isArray(data.usage))) {
593
+ throw new Error("Tangle usage must be an object");
594
+ }
595
+ if (data.tokenUsage !== undefined &&
596
+ (!data.tokenUsage ||
597
+ typeof data.tokenUsage !== "object" ||
598
+ Array.isArray(data.tokenUsage))) {
599
+ throw new Error("Tangle token usage must be an object");
600
+ }
283
601
  const usageRecord = data.usage && typeof data.usage === "object"
284
602
  ? data.usage
285
603
  : data.tokenUsage && typeof data.tokenUsage === "object"
286
604
  ? data.tokenUsage
287
605
  : data;
288
- const inputTokens = finiteNumber(usageRecord.inputTokens) ??
289
- finiteNumber(usageRecord.tokensIn) ??
290
- finiteNumber(usageRecord.prompt_tokens);
291
- const outputTokens = finiteNumber(usageRecord.outputTokens) ??
292
- finiteNumber(usageRecord.tokensOut) ??
293
- finiteNumber(usageRecord.completion_tokens);
294
- const cost = finiteNumber(usageRecord.cost) ??
295
- finiteNumber(usageRecord.costUsd) ??
296
- finiteNumber(usageRecord.totalCostUsd) ??
297
- finiteNumber(data.costUsd) ??
298
- finiteNumber(data.totalCostUsd);
606
+ const inputTokens = firstValidatedNumber(usageRecord, ["inputTokens", "tokensIn", "prompt_tokens"], "input token count", true);
607
+ const outputTokens = firstValidatedNumber(usageRecord, ["outputTokens", "tokensOut", "completion_tokens"], "output token count", true);
608
+ const nestedCost = firstValidatedNumber(usageRecord, ["cost", "costUsd", "totalCostUsd"], "usage cost", false);
609
+ const topLevelCost = firstValidatedNumber(data, ["costUsd", "totalCostUsd"], "result cost", false);
610
+ const cost = nestedCost ?? topLevelCost;
299
611
  if (inputTokens === undefined && outputTokens === undefined && cost === undefined)
300
612
  return undefined;
301
613
  return {
@@ -304,6 +616,22 @@ function tokenUsageFromData(data) {
304
616
  ...(cost !== undefined ? { cost } : {}),
305
617
  };
306
618
  }
619
+ function firstValidatedNumber(record, fields, label, integer) {
620
+ let selected;
621
+ for (const field of fields) {
622
+ const value = record[field];
623
+ if (value === undefined)
624
+ continue;
625
+ if (typeof value !== "number" ||
626
+ !Number.isFinite(value) ||
627
+ value < 0 ||
628
+ (integer && !Number.isSafeInteger(value))) {
629
+ throw new Error(`Tangle ${label} is invalid`);
630
+ }
631
+ selected ??= value;
632
+ }
633
+ return selected;
634
+ }
307
635
  function statusFromUnknown(status) {
308
636
  if (status === "pending" || status === "provisioning" || status === "running")
309
637
  return status;
@@ -318,9 +646,6 @@ function sessionStatusFromUnknown(status) {
318
646
  return status;
319
647
  return statusFromUnknown(status);
320
648
  }
321
- function finiteNumber(value) {
322
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
323
- }
324
649
  export function defaultTangleSandboxCapabilities() {
325
650
  return {
326
651
  profile: {
@@ -347,7 +672,7 @@ export function defaultTangleSandboxCapabilities() {
347
672
  streaming: { live: true, replay: true, detach: true, turnIdempotency: true },
348
673
  sessions: { continue: true, list: true, messages: true },
349
674
  workspace: { read: true, write: true, exec: true, git: true, upload: true, download: true },
350
- branching: { checkpoint: true, fork: true },
675
+ branching: { checkpoint: false, fork: false },
351
676
  placement: true,
352
677
  usage: true,
353
678
  confidential: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,10 +31,10 @@
31
31
  "LICENSE"
32
32
  ],
33
33
  "dependencies": {
34
- "@tangle-network/agent-interface": "0.40.0"
34
+ "@tangle-network/agent-interface": "0.41.0"
35
35
  },
36
36
  "peerDependencies": {
37
- "@tangle-network/sandbox": ">=0.13.0-develop.20260726215211.b0dddb3 <1.0.0"
37
+ "@tangle-network/sandbox": ">=0.17.0 <1.0.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@tangle-network/sandbox": {
@@ -42,11 +42,11 @@
42
42
  }
43
43
  },
44
44
  "devDependencies": {
45
- "@tangle-network/sandbox": "0.13.0-develop.20260726215211.b0dddb3",
45
+ "@tangle-network/sandbox": "0.17.0",
46
46
  "@types/node": "25.6.0",
47
47
  "typescript": "^6.0.3",
48
48
  "vitest": "^4.1.5",
49
- "@tangle-network/agent-provider-testkit": "0.3.10"
49
+ "@tangle-network/agent-provider-testkit": "0.4.0"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsc -p tsconfig.json",