@tangle-network/agent-provider-tangle 0.8.0 → 0.10.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,8 +1,11 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
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.22.0.
5
- The floor stays at 0.19.6 although deployment capability discovery (`box.capabilities()`) needs 0.22.0: the adapter feature-detects that method, so a consumer on an older SDK keeps working and claims no retained control instead of failing to load.
4
+ The peer range is `>=0.23.0 <1.0.0`, and this package is developed and tested against 0.23.0.
5
+ The floor is 0.23.0 because the response path needs `session.respondToInteraction`, which first shipped there.
6
+ The adapter feature-detects that method, so an older SDK claims no interactions rather than failing to load.
7
+ The floor stands anyway: the earlier answer path resolves the session's first outstanding question rather than the one a response names, so a response meant for one ask resolves another and reports success.
8
+ This adapter never falls back to it.
6
9
 
7
10
  ```ts
8
11
  import { Sandbox } from '@tangle-network/sandbox'
@@ -15,6 +18,8 @@ const provider = createTangleProvider({
15
18
 
16
19
  Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`.
17
20
  The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled.
21
+ Created and reconstructed environments expose a recursively frozen Sandbox metadata snapshot for constant-time annotation checks.
22
+ Sandbox metadata can include caller-authored values and does not authenticate its author.
18
23
  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.
19
24
  Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session.
20
25
  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`.
@@ -45,12 +50,40 @@ Each deployment flag this adapter reads gates the claims it backs, and no flag i
45
50
  | `cancel.idempotent` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
46
51
  | `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
47
52
  | `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
53
+ | `interactions.responseDedupe` | `interactions`, `environment.respondToInteraction`, `session.respondToInteraction` |
48
54
 
49
55
  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.
50
56
  `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.
51
57
  A claim takes its operation with it: `streaming.detach` gates `dispatch()`, and `session()` stands while any of `streaming.detach`, `streaming.replay`, or `sessions.continue` stands.
52
58
  A missing flag means unknown, and unknown is never a claim.
53
59
 
60
+ Answering an ask rests on `interactions.responseDedupe` alone, and on nothing retained control needs.
61
+ The adapter keeps no record of the responses it sent, so every replay answer comes from the deployment: repeating a command returns the recorded acknowledgement, and a different answer for a recorded ask is refused as `already_resolved_different`.
62
+ A deployment that does not record what it acknowledges therefore claims no interactions, even where the local method exists, because an unrecorded response cannot be retried without risking a second answer to a running agent.
63
+ The claim and the two methods stand or fall together.
64
+
65
+ ## Answering an interaction
66
+
67
+ `respondToInteraction` takes the canonical `InteractionResponseCommand` and returns the canonical `InteractionAcknowledgement`.
68
+ It is offered on the environment and on a session handle; the environment routes the command to the session its binding names.
69
+ The command carries only the answer the caller supplied. No field is filled in on the caller's behalf, and an answer the outstanding ask's spec rejects is refused with the field named.
70
+
71
+ | Result | Acknowledgement status |
72
+ | --- | --- |
73
+ | The deployment recorded and delivered the response | `accepted` |
74
+ | The deployment already holds this exact response | `already_resolved_same` |
75
+ | The deployment already holds a different response for the ask | `already_resolved_different`, message naming the recorded digest |
76
+ | The command names another provider, environment, or session | `binding_mismatch` |
77
+ | The deployment refuses the binding | `binding_mismatch` |
78
+ | The ask is unknown to the deployment | `unknown_interaction` |
79
+ | The session is unknown to the deployment | `unknown_run` |
80
+ | The ask left the outstanding set on a deadline | `expired` |
81
+ | The ask's spec rejects the answer | `invalid_response`, message naming each field |
82
+ | The response is recorded and delivery is unconfirmed | `transport_failure`, `retryable: true` |
83
+ | The request did not reach the route | `transport_failure`, `retryable: true` |
84
+
85
+ A refusal is never reported as a success, and a recorded response whose delivery the deployment does not confirm is never reported as `accepted`.
86
+
54
87
  Four inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a `null` document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), and a capability read that fails.
55
88
  In each case the environment omits `dispatch` and `session`, so a caller never selects an action the deployment will reject.
56
89
  A document that leaves a flag unset is not one of them.
@@ -73,6 +106,62 @@ It also rejects `contextTransfer` and `nativeContinuation` inputs explicitly unt
73
106
  The adapter never advertises `branching.checkpoint` or `branching.fork`.
74
107
  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.
75
108
 
109
+ ## Environment observation
110
+
111
+ `environment.observe()` returns the normalized `AgentEnvironmentObservation`.
112
+ Every surface carries a freshness discriminator, so a value the Sandbox SDK does not report is visibly absent instead of arriving as a measured zero.
113
+ The `observation` capability flag for a surface is true only when a source can put a value on it for that environment; the observation itself always carries the surface, with `unavailable` and its reason when there is nothing to report.
114
+
115
+ | Surface | Sandbox source | State when the source is missing |
116
+ | --- | --- | --- |
117
+ | `identity` | provider name and `box.id` | always known |
118
+ | `lifecycle.status` | `box.status` after a refresh | `stale` with the refresh failure |
119
+ | `lifecycle.cleanup` | `box.expiresAt` as a scheduled retirement | omitted from the lifecycle value |
120
+ | `endpoint` | scheme, host, and explicit port of `connection.runtimeUrl` | `unavailable` |
121
+ | `placement.verified` | `client.describePlacement(box)` | `unavailable` |
122
+ | `resources.requested` | the `create()` resource request | omitted on an environment rebuilt by id |
123
+ | `resources.effective` | cgroup `memoryLimitMb` and the attached GPU lease | `unavailable` |
124
+ | `resourceUse.current` / `peak` | cgroup `memoryCurrentMb` / `memoryPeakMb` | `unavailable` |
125
+ | `modelUsage` | the newest execution this handle measured | `unavailable` |
126
+ | `computeBilling` | the GPU lease's billed or estimated customer cost | `unavailable` |
127
+ | `accountUsage` | `client.subscription()` and `client.usage()` | `unavailable` |
128
+
129
+ Four quantities have no Sandbox source at all, and the adapter reports them as absent rather than deriving them.
130
+ There is no effective CPU or disk figure anywhere in the SDK, so `resources.effective` carries memory and the accelerator only.
131
+ The cgroup reports cumulative CPU microseconds, which is not a utilization figure, so `resourceUse` carries memory only rather than a rate computed from one sample.
132
+ Sandbox prices an attached GPU lease and publishes no per-sandbox container compute cost, so `computeBilling` is unavailable on an environment with no lease.
133
+ `lifecycle.continuity` and `lifecycle.persistence` have no readable source and are omitted.
134
+
135
+ Two account values are reported as absent on purpose.
136
+ A negative credit balance under an overage plan cannot be stated as non-negative remaining credit, and an account with no concurrent-sandbox ceiling has no quota to state; reporting either as zero would hide a debt or invent a limit.
137
+
138
+ `resources.requested` is the request the adapter sends, not the request the caller typed.
139
+ The contract states CPU, memory, and disk as `cpu`/`memoryMb`/`diskMb` while Sandbox reads `cpuCores`/`memoryMB`/`diskGB`, so the adapter translates them; a disk size that is not a whole number of gibibytes is refused rather than rounded.
140
+ A `resources.gpu` class becomes one accelerator device, which is the Sandbox default device count for a request that names only its class.
141
+
142
+ The observation makes two calls of its own — the account subscription and usage counters — plus the cgroup sample and a refresh.
143
+ A hop that fails does not fail the observation: its surface carries the transport's own message as the reason, and a value the refresh could not renew is reported as `stale` rather than as current.
144
+
145
+ Nothing in the observation can carry a credential.
146
+ The endpoint holds only a scheme, a host, and an explicit port; userinfo, path, and query are dropped, and the bearer beside the runtime URL is never read.
147
+
148
+ ## Interactive terminal
149
+
150
+ `environment.attachTerminal(request)` opens or reattaches one PTY over the sandbox terminal WebSocket, and `environment.terminal(id)` returns the live handle.
151
+ The four `interactiveTerminal` flags rest on one fact — the sandbox serves the PTY socket and reports terminal metadata — because all four operations reach the caller through one socket from one linked SDK.
152
+
153
+ Attach uses the request's `connectionId`, else its `terminalSessionId`, else a generated id; reusing an id reattaches the PTY and replays its retained screen, which the runtime reports as `reattached`.
154
+ `mode: "logical"` is refused: Sandbox resumes a terminal only by attaching it, and serving an attach the caller did not ask for would start a PTY behind their back.
155
+
156
+ `events()` replays retained frames from an EXCLUSIVE `since` cursor and then continues until the terminal exits.
157
+ Only `output` frames carry a sequence; `ready`, `resize`, `error`, and `exit` keep their place in the same ordered log.
158
+ A cursor the retained buffer no longer holds is refused, because resuming after the gap would drop output the consumer believes it received.
159
+
160
+ Every reference states an expiry one detach window past the newest activity the adapter can prove, and `input` and `resize` refuse a reference that is expired or no longer running.
161
+ `attachCount` counts the attaches this environment holds, not the runtime's own viewers.
162
+ `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.
163
+ 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.
164
+
76
165
  Pass `exactProcess: {}` only when the Sandbox deployment supports `agent: false` creates and reports `metadata.runtimeMode: "control"`.
77
166
  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.
78
167
  Set `teamId` inside `exactProcess` to scope create, lookup, and recovery to one team.
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export type { TangleExactProcessOptions } from "./tangle-types.js";
2
2
  export * from "./tangle-types.js";
3
3
  export { createTangleProvider } from "./tangle-provider.js";
4
4
  export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
5
+ export { safeEndpointFromConnection } from "./tangle-observation.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./tangle-types.js";
2
2
  export { createTangleProvider } from "./tangle-provider.js";
3
3
  export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
4
+ export { safeEndpointFromConnection } from "./tangle-observation.js";
@@ -1,6 +1,8 @@
1
1
  import type { AgentEnvironmentCapabilities, HarnessType } from "@tangle-network/agent-interface";
2
2
  import type { SandboxClientLike, SandboxInstanceLike } from "./tangle-types.js";
3
3
  import type { DeploymentCapabilitySupport } from "./tangle-deployment-capabilities.js";
4
+ import type { ResourceProfile } from "@tangle-network/agent-interface";
5
+ import { type ObservationSurfaceSupport } from "./tangle-observation.js";
4
6
  /**
5
7
  * The full capability document this adapter supports when the Sandbox client
6
8
  * implements every optional method.
@@ -29,8 +31,14 @@ export interface SandboxCapabilitySupport {
29
31
  placement: boolean;
30
32
  destroy: boolean;
31
33
  cancelRun: boolean;
34
+ /** The session handle exposes the digest-bound interaction command route. */
35
+ respondToInteraction: boolean;
36
+ /** Per-surface sources for the normalized observation. */
37
+ observation: ObservationSurfaceSupport;
38
+ /** The sandbox serves the PTY socket and reports terminal metadata. */
39
+ interactiveTerminal: boolean;
32
40
  }
33
- export declare function sandboxCapabilitySupport(box: SandboxInstanceLike, client: SandboxClientLike): SandboxCapabilitySupport;
41
+ export declare function sandboxCapabilitySupport(box: SandboxInstanceLike, client: SandboxClientLike, requestedResources?: ResourceProfile): SandboxCapabilitySupport;
34
42
  /**
35
43
  * Establish client-stage facts before any sandbox exists. Two sources: the
36
44
  * client's own members (get, describePlacement) and, for an SDK-backed client,
@@ -53,6 +61,19 @@ export declare function clientCapabilitySupport(client: SandboxClientLike): Sand
53
61
  * can call is not a run the service retains.
54
62
  */
55
63
  export declare function tangleRetainedControlSupported(declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport, deployment: DeploymentCapabilitySupport): boolean;
64
+ /**
65
+ * Decide whether answering an interaction may be claimed.
66
+ *
67
+ * Two independent fact sets must agree, as they do for retained control. The
68
+ * adapter surface must be able to send the command: a session handle exposing
69
+ * the digest-bound route. The deployment must record what it acknowledges,
70
+ * because this adapter keeps no resolution record of its own — every replay
71
+ * answer comes from the deployment. A deployment that leaves the flag unset
72
+ * refuses the claim even though the local method exists, since an
73
+ * unrecorded response cannot be retried without risking a second answer to a
74
+ * running agent.
75
+ */
76
+ export declare function tangleInteractionResponsesSupported(declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport, deployment: DeploymentCapabilitySupport): boolean;
56
77
  /**
57
78
  * Narrow a declared capability document to established facts.
58
79
  *
@@ -1,6 +1,8 @@
1
1
  import { harnessSystemPromptIntents } from "@tangle-network/agent-interface";
2
2
  import { SandboxInstance } from "@tangle-network/sandbox";
3
3
  import { ADAPTER_CEILING_DEPLOYMENT, deploymentBacksRetainedControl, } from "./tangle-deployment-capabilities.js";
4
+ import { clientObservationSurfaceSupport, observationSurfaceSupport, } from "./tangle-observation.js";
5
+ import { sandboxBacksInteractiveTerminal } from "./tangle-terminal.js";
4
6
  /**
5
7
  * The full capability document this adapter supports when the Sandbox client
6
8
  * implements every optional method.
@@ -42,6 +44,26 @@ export function defaultTangleSandboxCapabilities(harness) {
42
44
  // together over one Sandbox surface, and the capability schema refuses
43
45
  // a partial block, so they stand or fall on the same fact set.
44
46
  sessions: { continue: true, list: false, messages: false },
47
+ // Answering an ask is declared as intent and stripped by narrowing unless
48
+ // the session exposes the command route and the deployment discloses its
49
+ // durable response record. Three claims are bounded by what the adapter
50
+ // can establish rather than by what the route carries:
51
+ // - `secretAnswers` false, and no `secret` answer field: the deployment
52
+ // discloses nothing about resolving a one-use secret handle, and an
53
+ // undisclosed fact is not a claim.
54
+ // - `responseScopes` names `interaction` alone, for the same reason: no
55
+ // document states that a broader grant is honored on reuse.
56
+ // - `concurrentRequests` true: the command names the ask it answers, so
57
+ // several outstanding asks each take their own exact response.
58
+ interactions: {
59
+ kinds: ["question", "permission", "plan"],
60
+ answerFieldTypes: ["text", "number", "boolean", "select"],
61
+ responseScopes: ["interaction"],
62
+ secretAnswers: false,
63
+ concurrentRequests: true,
64
+ replay: true,
65
+ responseIdempotency: true,
66
+ },
45
67
  retainedControl: {
46
68
  exactRunIdentity: true,
47
69
  resultIdentity: true,
@@ -57,11 +79,32 @@ export function defaultTangleSandboxCapabilities(harness) {
57
79
  // Confidential execution needs verified attestation evidence, which this
58
80
  // adapter does not yet obtain, so it is never declared by default.
59
81
  confidential: false,
82
+ // Observation surfaces are declared as intent and narrowed per sandbox to
83
+ // the sources that can put a value on each one.
84
+ observation: {
85
+ identity: true,
86
+ lifecycle: true,
87
+ endpoint: true,
88
+ placement: true,
89
+ resources: true,
90
+ resourceUse: true,
91
+ modelUsage: true,
92
+ computeBilling: true,
93
+ accountUsage: true,
94
+ },
95
+ // The four terminal operations rest on one fact: the sandbox serves the
96
+ // PTY socket and reports terminal metadata. They stand or fall together.
97
+ interactiveTerminal: {
98
+ attach: true,
99
+ input: true,
100
+ resize: true,
101
+ reattach: true,
102
+ },
60
103
  };
61
104
  }
62
105
  // One reserved id names both probe handles; neither ever reaches the service.
63
106
  const CAPABILITY_PROBE_ID = "__tangle-capability-probe__";
64
- export function sandboxCapabilitySupport(box, client) {
107
+ export function sandboxCapabilitySupport(box, client, requestedResources) {
65
108
  let session;
66
109
  if (typeof box.session === "function") {
67
110
  try {
@@ -83,6 +126,9 @@ export function sandboxCapabilitySupport(box, client) {
83
126
  placement: typeof client.describePlacement === "function",
84
127
  destroy: typeof box.delete === "function",
85
128
  cancelRun: typeof session?.cancelRun === "function",
129
+ respondToInteraction: typeof session?.respondToInteraction === "function",
130
+ observation: observationSurfaceSupport(box, client, requestedResources),
131
+ interactiveTerminal: sandboxBacksInteractiveTerminal(box),
86
132
  };
87
133
  }
88
134
  /**
@@ -121,9 +167,14 @@ function linkedSdkProbeInstance(client) {
121
167
  * re-measures them in `capabilitiesForSandbox`.
122
168
  */
123
169
  export function clientCapabilitySupport(client) {
170
+ const observation = clientObservationSurfaceSupport(client);
124
171
  const probe = linkedSdkProbeInstance(client);
125
- if (probe)
126
- return sandboxCapabilitySupport(probe, client);
172
+ if (probe) {
173
+ // The probe measures the linked SDK's method surface, so it decides the
174
+ // terminal transport. It carries no sandbox data, so the observation
175
+ // surfaces that rest on one environment's values stay at the ceiling.
176
+ return { ...sandboxCapabilitySupport(probe, client), observation };
177
+ }
127
178
  return {
128
179
  reconstruct: typeof client.get === "function",
129
180
  dispatchPrompt: true,
@@ -134,6 +185,9 @@ export function clientCapabilitySupport(client) {
134
185
  placement: typeof client.describePlacement === "function",
135
186
  destroy: true,
136
187
  cancelRun: false,
188
+ respondToInteraction: false,
189
+ observation,
190
+ interactiveTerminal: true,
137
191
  };
138
192
  }
139
193
  /**
@@ -158,6 +212,24 @@ export function tangleRetainedControlSupported(declared, support, deployment) {
158
212
  support.session &&
159
213
  support.cancelRun);
160
214
  }
215
+ /**
216
+ * Decide whether answering an interaction may be claimed.
217
+ *
218
+ * Two independent fact sets must agree, as they do for retained control. The
219
+ * adapter surface must be able to send the command: a session handle exposing
220
+ * the digest-bound route. The deployment must record what it acknowledges,
221
+ * because this adapter keeps no resolution record of its own — every replay
222
+ * answer comes from the deployment. A deployment that leaves the flag unset
223
+ * refuses the claim even though the local method exists, since an
224
+ * unrecorded response cannot be retried without risking a second answer to a
225
+ * running agent.
226
+ */
227
+ export function tangleInteractionResponsesSupported(declared, support, deployment) {
228
+ return (declared.interactions !== undefined &&
229
+ deployment.interactionResponses &&
230
+ support.session &&
231
+ support.respondToInteraction);
232
+ }
161
233
  /**
162
234
  * Narrow a declared capability document to established facts.
163
235
  *
@@ -214,13 +286,57 @@ export function narrowedTangleCapabilities(declared, support, deployment) {
214
286
  },
215
287
  placement: support.placement ? declared.placement : false,
216
288
  usage: false,
289
+ ...(declared.observation === undefined
290
+ ? {}
291
+ : { observation: narrowedObservation(declared.observation, support.observation) }),
292
+ ...(declared.interactiveTerminal === undefined
293
+ ? {}
294
+ : {
295
+ interactiveTerminal: narrowedInteractiveTerminal(declared.interactiveTerminal, support.interactiveTerminal),
296
+ }),
217
297
  };
218
- delete narrowed.interactions;
298
+ // A claimed block is passed through whole. Its sub-flags state what the
299
+ // route carries, not what one deployment reports, and the deployment's own
300
+ // fact has already decided whether the block survives at all.
301
+ if (!tangleInteractionResponsesSupported(declared, support, deployment)) {
302
+ delete narrowed.interactions;
303
+ }
219
304
  delete narrowed.nativeContinuation;
220
305
  if (!supportsRetainedControl)
221
306
  delete narrowed.retainedControl;
222
307
  return narrowed;
223
308
  }
309
+ /**
310
+ * Clear every observation surface no source backs. The flag states whether a
311
+ * value can be produced; the observation itself always carries the surface
312
+ * with its freshness state, so a cleared flag never turns into a missing key.
313
+ */
314
+ function narrowedObservation(declared, support) {
315
+ return {
316
+ identity: support.identity ? declared.identity : false,
317
+ lifecycle: support.lifecycle ? declared.lifecycle : false,
318
+ endpoint: support.endpoint ? declared.endpoint : false,
319
+ placement: support.placement ? declared.placement : false,
320
+ resources: support.resources ? declared.resources : false,
321
+ resourceUse: support.resourceUse ? declared.resourceUse : false,
322
+ modelUsage: support.modelUsage ? declared.modelUsage : false,
323
+ computeBilling: support.computeBilling ? declared.computeBilling : false,
324
+ accountUsage: support.accountUsage ? declared.accountUsage : false,
325
+ };
326
+ }
327
+ /**
328
+ * The four terminal operations reach the caller through one PTY socket from
329
+ * one linked SDK, so one fact decides them all: a sandbox that cannot serve
330
+ * the socket claims none of them.
331
+ */
332
+ function narrowedInteractiveTerminal(declared, supported) {
333
+ return {
334
+ attach: supported ? declared.attach : false,
335
+ input: supported ? declared.input : false,
336
+ resize: supported ? declared.resize : false,
337
+ reattach: supported ? declared.reattach : false,
338
+ };
339
+ }
224
340
  /**
225
341
  * Narrow provider-level claims to facts the client can prove before any
226
342
  * sandbox exists.
@@ -1,4 +1,5 @@
1
1
  import { assertBoundedJson, boundedIdentifier, boundedString, MAX_ARRAY_LENGTH, MAX_MAP_ENTRIES, } from "./tangle-contract-safety.js";
2
+ import { sandboxResourcesFromResourceRequest } from "./tangle-resources.js";
2
3
  export function sandboxOptionsFromCreateInput(input, defaultBackend) {
3
4
  assertCreateInputShape(input);
4
5
  assertNoInlineSecretValues(input);
@@ -40,8 +41,6 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend) {
40
41
  }
41
42
  if (input.resources?.providerOptions)
42
43
  assertBoundedRecord(input.resources.providerOptions, "Tangle resource providerOptions");
43
- if (input.resources?.gpu !== undefined)
44
- boundedIdentifier(input.resources.gpu, "Tangle GPU");
45
44
  if (input.idempotencyKey !== undefined) {
46
45
  boundedIdentifier(input.idempotencyKey, "Tangle idempotency key");
47
46
  }
@@ -51,13 +50,7 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend) {
51
50
  throw new Error("Tangle workspace image cannot be empty");
52
51
  if (workspace.repoUrl === "")
53
52
  throw new Error("Tangle repository URL cannot be empty");
54
- for (const [name, value] of Object.entries(input.resources ?? {})) {
55
- if (name === "providerOptions" || name === "gpu")
56
- continue;
57
- if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
58
- throw new Error(`Tangle resource ${name} must be a positive safe integer`);
59
- }
60
- }
53
+ const resources = sandboxResourcesFromResourceRequest(input.resources);
61
54
  if (workspace.environment !== undefined && workspace.image !== undefined) {
62
55
  throw new Error("Tangle workspace cannot specify both environment and image");
63
56
  }
@@ -77,7 +70,7 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend) {
77
70
  },
78
71
  }
79
72
  : {}),
80
- ...(input.resources ? { resources: input.resources } : {}),
73
+ ...(resources ? { resources } : {}),
81
74
  ...(input.env ? { env: input.env } : {}),
82
75
  ...(Array.isArray(input.secrets) ? { secrets: input.secrets } : {}),
83
76
  ...(input.metadata ? { metadata: input.metadata } : {}),
@@ -21,6 +21,13 @@ export interface DeploymentCapabilitySupport {
21
21
  readonly eventReplay: boolean;
22
22
  /** Status and results select one execution, not the session's latest. */
23
23
  readonly executionScopedStatus: boolean;
24
+ /**
25
+ * Interaction responses are recorded durably, so repeating one replays the
26
+ * recorded acknowledgement instead of delivering a second answer to a
27
+ * running agent. The adapter holds no record of its own, so this fact alone
28
+ * decides whether a response can be retried safely.
29
+ */
30
+ readonly interactionResponses: boolean;
24
31
  }
25
32
  /**
26
33
  * The deployment backs nothing.
@@ -13,6 +13,7 @@ export const UNPROVEN_DEPLOYMENT = {
13
13
  canonicalCancellation: false,
14
14
  eventReplay: false,
15
15
  executionScopedStatus: false,
16
+ interactionResponses: false,
16
17
  };
17
18
  /**
18
19
  * The client stage's deployment input: this adapter's ceiling, not a fact.
@@ -34,6 +35,7 @@ export const ADAPTER_CEILING_DEPLOYMENT = {
34
35
  canonicalCancellation: true,
35
36
  eventReplay: true,
36
37
  executionScopedStatus: true,
38
+ interactionResponses: true,
37
39
  };
38
40
  /**
39
41
  * Read the deployment facts out of a capability document. Every flag this
@@ -50,6 +52,7 @@ export function deploymentCapabilitySupport(document) {
50
52
  document.cancel?.idempotent === true,
51
53
  eventReplay: document.runs?.eventReplay === true,
52
54
  executionScopedStatus: document.runs?.executionScopedStatus === true,
55
+ interactionResponses: document.interactions?.responseDedupe === true,
53
56
  };
54
57
  }
55
58
  /**
@@ -2,6 +2,7 @@ import type { AgentSession, AgentSessionRef, AgentTurnInput } from "@tangle-netw
2
2
  import type { AgentExactRunControlRef, AgentRunControlRef } from "@tangle-network/agent-interface";
3
3
  import type { SandboxEvent } from "@tangle-network/sandbox";
4
4
  import type { SandboxSessionLike } from "./tangle-types.js";
5
+ import type { ExecutionUsageLog } from "./tangle-usage-log.js";
5
6
  type ExactExecutionEventStream = (options: {
6
7
  sessionId: string;
7
8
  executionId: string;
@@ -14,6 +15,13 @@ type ExactExecutionEventStream = (options: {
14
15
  * document grants retained control. Canonical cancellation is offered only
15
16
  * under that grant: a `cancelRun` method the deployment does not honor is an
16
17
  * action the caller selects and finds rejected on the wire.
18
+ * @param interactionResponses Whether the environment's narrowed capability
19
+ * document claims interaction responses. The method is offered only under
20
+ * that claim, so a caller never selects an answer the deployment cannot
21
+ * record.
22
+ * @param usageLog Sink for the token usage each execution measured. The
23
+ * environment observation reads it, because Sandbox reports usage per
24
+ * execution result and never for the environment.
17
25
  */
18
- export declare function sandboxSessionAsAgentSession(session: SandboxSessionLike, controlRef: AgentRunControlRef | undefined, provider: string, environmentId: string, dispatch: ((input: AgentTurnInput) => Promise<AgentSessionRef>) | undefined, exactExecutionEvents: ExactExecutionEventStream | undefined, retainedControl: boolean): AgentSession;
26
+ export declare function sandboxSessionAsAgentSession(session: SandboxSessionLike, controlRef: AgentRunControlRef | undefined, provider: string, environmentId: string, dispatch: ((input: AgentTurnInput) => Promise<AgentSessionRef>) | undefined, exactExecutionEvents: ExactExecutionEventStream | undefined, retainedControl: boolean, interactionResponses: boolean, usageLog?: ExecutionUsageLog): AgentSession;
19
27
  export {};
@@ -1,24 +1,44 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { AgentTurnInputSchema } from "@tangle-network/agent-interface";
3
3
  import { AgentRunCancellationAcknowledgementSchema, AgentRunCancellationRequestSchema, agentRunCancellationAcknowledgementMatchesRequest, } from "@tangle-network/agent-interface";
4
- import { environmentEventFromSandboxEvent, isSandboxConnectionMarker, sandboxEventIdentity, } from "./tangle-events.js";
4
+ import { carriedSessionIds, 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
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
+ import { tangleInteractionResponder } from "./tangle-interaction-response.js";
10
11
  import { hasReplayPayload, interruptExecutionAfterAbort, sessionPromptRequestDigest, } from "./tangle-environment-control.js";
11
12
  /**
12
13
  * @param retainedControl Whether the environment's narrowed capability
13
14
  * document grants retained control. Canonical cancellation is offered only
14
15
  * under that grant: a `cancelRun` method the deployment does not honor is an
15
16
  * action the caller selects and finds rejected on the wire.
17
+ * @param interactionResponses Whether the environment's narrowed capability
18
+ * document claims interaction responses. The method is offered only under
19
+ * that claim, so a caller never selects an answer the deployment cannot
20
+ * record.
21
+ * @param usageLog Sink for the token usage each execution measured. The
22
+ * environment observation reads it, because Sandbox reports usage per
23
+ * execution result and never for the environment.
16
24
  */
17
- export function sandboxSessionAsAgentSession(session, controlRef, provider, environmentId, dispatch, exactExecutionEvents, retainedControl) {
25
+ export function sandboxSessionAsAgentSession(session, controlRef, provider, environmentId, dispatch, exactExecutionEvents, retainedControl, interactionResponses, usageLog) {
26
+ const measured = (executionId, result) => {
27
+ usageLog?.record(executionId, result.usage);
28
+ return result;
29
+ };
18
30
  let activeControlRef = controlRef
19
31
  ? resolveRetainedSessionControlRef(controlRef, session.id, provider, environmentId)
20
32
  : undefined;
21
33
  let promptInFlight = false;
34
+ const respondToInteraction = interactionResponses && typeof session.respondToInteraction === "function"
35
+ ? tangleInteractionResponder({
36
+ session,
37
+ sessionId: session.id,
38
+ provider,
39
+ environmentId,
40
+ })
41
+ : undefined;
22
42
  const cancelRunMethod = retainedControl ? session.cancelRun : undefined;
23
43
  const cancelRun = typeof cancelRunMethod === "function"
24
44
  ? async (request, options) => {
@@ -106,9 +126,13 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
106
126
  markerIdentity.executionId !== executionId) {
107
127
  throw new Error("Tangle exact session connection identified a different executionId");
108
128
  }
109
- if (markerIdentity.sessionId !== undefined &&
110
- markerIdentity.sessionId !== session.id) {
111
- throw new Error("Tangle exact session connection identified a different sessionId");
129
+ // Every position that names a session on the marker names the
130
+ // session the stream was opened for. The marker carries no native
131
+ // id, so no position is exempt.
132
+ for (const carried of carriedSessionIds(markerIdentity)) {
133
+ if (carried !== session.id) {
134
+ throw new Error("Tangle exact session connection identified a different sessionId");
135
+ }
112
136
  }
113
137
  continue;
114
138
  }
@@ -143,10 +167,10 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
143
167
  const resultRecord = validatedSandboxPromptResult(result);
144
168
  if (resultRecord.executionId !== expectedExecutionId)
145
169
  throw new Error("Tangle session result did not confirm its exact executionId");
146
- return agentTurnResultFromPromptRecord(resultRecord, {
170
+ return measured(expectedExecutionId, agentTurnResultFromPromptRecord(resultRecord, {
147
171
  sessionId: session.id,
148
172
  controlRef: activeControlRef,
149
- });
173
+ }));
150
174
  },
151
175
  async prompt(input) {
152
176
  AgentTurnInputSchema.parse(input);
@@ -199,10 +223,10 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
199
223
  if (resultRecord.executionId !== nextControlRef.executionId) {
200
224
  throw new Error("Tangle detached session prompt did not confirm its exact executionId");
201
225
  }
202
- return agentTurnResultFromPromptRecord(resultRecord, {
226
+ return measured(nextControlRef.executionId, agentTurnResultFromPromptRecord(resultRecord, {
203
227
  sessionId: session.id,
204
228
  controlRef: nextControlRef,
205
- });
229
+ }));
206
230
  }
207
231
  const sourceControlRef = requestedControlRef ?? activeControlRef;
208
232
  const replay = input.lastEventId !== undefined;
@@ -277,7 +301,7 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
277
301
  }
278
302
  const nextControlRef = targetControlRef;
279
303
  activeControlRef = nextControlRef;
280
- return agentTurnResultFromPromptRecord(resultRecord, {
304
+ return measured(executionId, agentTurnResultFromPromptRecord(resultRecord, {
281
305
  sessionId: session.id,
282
306
  controlRef: nextControlRef,
283
307
  ...(input.contextTransfer
@@ -286,7 +310,7 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
286
310
  ...(input.contextTransfer
287
311
  ? { contextTransferRequested: true }
288
312
  : {}),
289
- });
313
+ }));
290
314
  }
291
315
  catch (error) {
292
316
  if (input.signal?.aborted && input.detach !== true) {
@@ -311,5 +335,6 @@ export function sandboxSessionAsAgentSession(session, controlRef, provider, envi
311
335
  throw new Error("Tangle sandbox did not confirm cancellation");
312
336
  },
313
337
  ...(cancelRun ? { cancelRun } : {}),
338
+ ...(respondToInteraction ? { respondToInteraction } : {}),
314
339
  };
315
340
  }
@@ -1,4 +1,4 @@
1
- import type { AgentEnvironment, AgentEnvironmentCapabilities } from "@tangle-network/agent-interface/environment-provider";
1
+ import type { AgentEnvironment, AgentEnvironmentCapabilities, ResourceProfile } from "@tangle-network/agent-interface/environment-provider";
2
2
  import type { SandboxClientLike, SandboxInstanceLike } from "./tangle-types.js";
3
3
  /**
4
4
  * Compose one concrete sandbox into an environment.
@@ -16,7 +16,13 @@ import type { SandboxClientLike, SandboxInstanceLike } from "./tangle-types.js";
16
16
  * nothing and keeps claiming nothing: the exposed operations and the document
17
17
  * are composed together and a caller may already hold either one. Compose the
18
18
  * environment again through `provider.get(id)` once the sandbox is running.
19
+ *
20
+ * @param request What the create call asked for. An environment rebuilt by id
21
+ * carries none of it, so its observation reports the requested compute shape
22
+ * as absent instead of restating a request it never saw.
19
23
  */
20
24
  export declare function sandboxInstanceAsEnvironment(box: SandboxInstanceLike, providerName: string, client: SandboxClientLike, declaredCapabilities: AgentEnvironmentCapabilities, operation?: {
21
25
  signal?: AbortSignal;
26
+ }, request?: {
27
+ resources?: ResourceProfile;
22
28
  }): Promise<AgentEnvironment>;