@tangle-network/agent-provider-tangle 0.14.1 → 0.14.2

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,19 +1,19 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
3
  Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
4
- The peer range is `>=0.33.1 <1.0.0`, and this package is developed and tested against 0.33.1.
5
- The floor is 0.33.1 because workspace branching uses the keyed snapshot, fork,
4
+ The peer range is `>=0.34.0 <1.0.0`, and this package is developed and tested against 0.34.3.
5
+ The floor is 0.34.0 because workspace branching uses the keyed snapshot, fork,
6
6
  lookup, and cleanup operations added to that SDK.
7
7
  The provider fails closed when the configured backend or its catalog entry cannot be read.
8
8
  Newer SDKs may also provide `getBackend()` as a lookup over the same catalog.
9
9
 
10
10
  ```ts
11
- import { Sandbox } from '@tangle-network/sandbox'
12
- import { createTangleProvider } from '@tangle-network/agent-provider-tangle'
11
+ import { Sandbox } from "@tangle-network/sandbox";
12
+ import { createTangleProvider } from "@tangle-network/agent-provider-tangle";
13
13
 
14
14
  const provider = createTangleProvider({
15
15
  client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
16
- })
16
+ });
17
17
  ```
18
18
 
19
19
  Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`.
@@ -43,16 +43,16 @@ One provider reaches deployments of different ages, which is why the environment
43
43
 
44
44
  Each deployment flag this adapter reads gates the claims it backs, and no flag is read that gates nothing:
45
45
 
46
- | Deployment flag | Claims it gates |
47
- | --- | --- |
48
- | `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
46
+ | Deployment flag | Claims it gates |
47
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
48
+ | `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
49
49
  | `dispatch.executionIdOnAdmission` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
50
- | `cancel.canonicalRunCancellation` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
51
- | `cancel.digestBound` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
52
- | `cancel.idempotent` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
53
- | `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
54
- | `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
55
- | `interactions.responseDedupe` | `interactions`, `environment.respondToInteraction`, `session.respondToInteraction` |
50
+ | `cancel.canonicalRunCancellation` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
51
+ | `cancel.digestBound` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
52
+ | `cancel.idempotent` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
53
+ | `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
54
+ | `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
55
+ | `interactions.responseDedupe` | `interactions`, `environment.respondToInteraction`, `session.respondToInteraction` |
56
56
 
57
57
  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.
58
58
  `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.
@@ -70,19 +70,19 @@ The claim and the two methods stand or fall together.
70
70
  It is offered on the environment and on a session handle; the environment routes the command to the session its binding names.
71
71
  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.
72
72
 
73
- | Result | Acknowledgement status |
74
- | --- | --- |
75
- | The deployment recorded and delivered the response | `accepted` |
76
- | The deployment already holds this exact response | `already_resolved_same` |
73
+ | Result | Acknowledgement status |
74
+ | ------------------------------------------------------------- | ---------------------------------------------------------------- |
75
+ | The deployment recorded and delivered the response | `accepted` |
76
+ | The deployment already holds this exact response | `already_resolved_same` |
77
77
  | The deployment already holds a different response for the ask | `already_resolved_different`, message naming the recorded digest |
78
- | The command names another provider, environment, or session | `binding_mismatch` |
79
- | The deployment refuses the binding | `binding_mismatch` |
80
- | The ask is unknown to the deployment | `unknown_interaction` |
81
- | The session is unknown to the deployment | `unknown_run` |
82
- | The ask left the outstanding set on a deadline | `expired` |
83
- | The ask's spec rejects the answer | `invalid_response`, message naming each field |
84
- | The response is recorded and delivery is unconfirmed | `transport_failure`, `retryable: true` |
85
- | The request did not reach the route | `transport_failure`, `retryable: true` |
78
+ | The command names another provider, environment, or session | `binding_mismatch` |
79
+ | The deployment refuses the binding | `binding_mismatch` |
80
+ | The ask is unknown to the deployment | `unknown_interaction` |
81
+ | The session is unknown to the deployment | `unknown_run` |
82
+ | The ask left the outstanding set on a deadline | `expired` |
83
+ | The ask's spec rejects the answer | `invalid_response`, message naming each field |
84
+ | The response is recorded and delivery is unconfirmed | `transport_failure`, `retryable: true` |
85
+ | The request did not reach the route | `transport_failure`, `retryable: true` |
86
86
 
87
87
  A refusal is never reported as a success, and a recorded response whose delivery the deployment does not confirm is never reported as `accepted`.
88
88
 
@@ -150,24 +150,24 @@ Clean branch resources before deleting the source or use a platform reaper.
150
150
  import {
151
151
  workspaceCheckpointRequestDigest,
152
152
  workspaceForkRequestDigest,
153
- } from '@tangle-network/agent-interface'
153
+ } from "@tangle-network/agent-interface";
154
154
 
155
155
  const checkpoint = await environment.workspaceBranching?.checkpoint({
156
156
  source: exactRun,
157
- idempotencyKey: 'checkpoint-before-analysis',
157
+ idempotencyKey: "checkpoint-before-analysis",
158
158
  requestDigest: workspaceCheckpointRequestDigest({ source: exactRun }),
159
- })
159
+ });
160
160
 
161
- if (checkpoint?.status === 'created' || checkpoint?.status === 'replayed') {
161
+ if (checkpoint?.status === "created" || checkpoint?.status === "replayed") {
162
162
  const fork = await environment.workspaceBranching?.fork({
163
163
  checkpoint: checkpoint.checkpoint,
164
- placement: { kind: 'sandbox', sandboxId: 'analysis-worker' },
165
- idempotencyKey: 'analysis-worker',
164
+ placement: { kind: "sandbox", sandboxId: "analysis-worker" },
165
+ idempotencyKey: "analysis-worker",
166
166
  requestDigest: workspaceForkRequestDigest({
167
167
  checkpoint: checkpoint.checkpoint,
168
- placement: { kind: 'sandbox', sandboxId: 'analysis-worker' },
168
+ placement: { kind: "sandbox", sandboxId: "analysis-worker" },
169
169
  }),
170
- })
170
+ });
171
171
  }
172
172
  ```
173
173
 
@@ -192,7 +192,7 @@ const provider = createTangleProvider({
192
192
  client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
193
193
  confidentialAttestationVerifier: async ({ report, attestation }) =>
194
194
  (await verifyTangleQuote({ report, attestation })) ?? null,
195
- })
195
+ });
196
196
  ```
197
197
 
198
198
  The provider stores the raw report in `ConfidentialAttestation.quote` through
@@ -208,19 +208,19 @@ reports before verification.
208
208
  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.
209
209
  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.
210
210
 
211
- | Surface | Sandbox source | State when the source is missing |
212
- | --- | --- | --- |
213
- | `identity` | provider name and `box.id` | always known |
214
- | `lifecycle.status` | `box.status` after a refresh | `stale` with the refresh failure |
215
- | `lifecycle.cleanup` | `box.expiresAt` as a scheduled retirement | omitted from the lifecycle value |
216
- | `endpoint` | scheme, host, and explicit port of `connection.runtimeUrl` | `unavailable` |
217
- | `placement.verified` | `client.describePlacement(box)` | `unavailable` |
218
- | `resources.requested` | the `create()` resource request | omitted on an environment rebuilt by id |
219
- | `resources.effective` | cgroup `memoryLimitMb` and the attached GPU lease | `unavailable` |
220
- | `resourceUse.current` / `peak` | cgroup `memoryCurrentMb` / `memoryPeakMb` | `unavailable` |
221
- | `modelUsage` | the newest execution this handle measured | `unavailable` |
222
- | `computeBilling` | the GPU lease's billed or estimated customer cost | `unavailable` |
223
- | `accountUsage` | `client.subscription()` and `client.usage()` | `unavailable` |
211
+ | Surface | Sandbox source | State when the source is missing |
212
+ | ------------------------------ | ---------------------------------------------------------- | --------------------------------------- |
213
+ | `identity` | provider name and `box.id` | always known |
214
+ | `lifecycle.status` | `box.status` after a refresh | `stale` with the refresh failure |
215
+ | `lifecycle.cleanup` | `box.expiresAt` as a scheduled retirement | omitted from the lifecycle value |
216
+ | `endpoint` | scheme, host, and explicit port of `connection.runtimeUrl` | `unavailable` |
217
+ | `placement.verified` | `client.describePlacement(box)` | `unavailable` |
218
+ | `resources.requested` | the `create()` resource request | omitted on an environment rebuilt by id |
219
+ | `resources.effective` | cgroup `memoryLimitMb` and the attached GPU lease | `unavailable` |
220
+ | `resourceUse.current` / `peak` | cgroup `memoryCurrentMb` / `memoryPeakMb` | `unavailable` |
221
+ | `modelUsage` | the newest execution this handle measured | `unavailable` |
222
+ | `computeBilling` | the GPU lease's billed or estimated customer cost | `unavailable` |
223
+ | `accountUsage` | `client.subscription()` and `client.usage()` | `unavailable` |
224
224
 
225
225
  Four quantities have no Sandbox source at all, and the adapter reports them as absent rather than deriving them.
226
226
  There is no effective CPU or disk figure anywhere in the SDK, so `resources.effective` carries memory and the accelerator only.
@@ -266,16 +266,16 @@ Set `teamId` inside `exactProcess` to scope create, lookup, and recovery to one
266
266
  const provider = createTangleProvider({
267
267
  client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
268
268
  exactProcess: {},
269
- })
269
+ });
270
270
 
271
271
  const environment = await provider.exactProcess!.create({
272
- image: 'ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>',
273
- egress: { mode: 'blocked' },
272
+ image: "ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>",
273
+ egress: { mode: "blocked" },
274
274
  maxLifetimeMs: 120_000,
275
275
  resources: { cpu: 1, memoryMb: 1024, diskMb: 1024 },
276
- metadata: { executionId: 'run-1' },
277
- idempotencyKey: 'run-1',
278
- })
276
+ metadata: { executionId: "run-1" },
277
+ idempotencyKey: "run-1",
278
+ });
279
279
  ```
280
280
 
281
281
  The adapter rejects ordinary sandboxes during create, recovery, and list operations.
@@ -10,8 +10,8 @@ export interface TangleInteractiveAgentRegistry {
10
10
  /**
11
11
  * Test the linked SDK surface without accepting any older interactive shape.
12
12
  *
13
- * The current public Sandbox release fails this test. That is intentional:
14
- * it has no receipt, control claim, or mutation acknowledgement protocol.
13
+ * Sandbox owns the exact reference, control claim, terminal binding, and
14
+ * mutation acknowledgements. An older handle shape fails this test closed.
15
15
  */
16
16
  export declare function sandboxBacksInteractiveAgent(box: SandboxInstanceLike): boolean;
17
17
  /**
@@ -1,21 +1,20 @@
1
- import { AgentInteractiveSessionAttachSchema, AgentInteractiveSessionControlClaimAcknowledgementSchema, AgentInteractiveSessionControlClaimRequestSchema, AgentInteractiveSessionPromptAcknowledgementSchema, AgentInteractiveSessionPromptCommandSchema, AgentInteractiveSessionRefSchema, AgentInteractiveSessionStatusSchema, AgentInteractiveSessionStopAcknowledgementSchema, AgentInteractiveSessionStopCommandSchema, agentInteractiveSessionControlClaimAcknowledgementMatchesRequest, agentInteractiveSessionControlClaimMatchesRef, agentInteractiveSessionPromptAcknowledgementMatchesCommand, agentInteractiveSessionRefMatchesStart, agentInteractiveSessionStopAcknowledgementMatchesCommand, canonicalCandidateDigest, exactAgentInteractiveSessionStart, } from "@tangle-network/agent-interface";
2
- import { parseBackendType } from "@tangle-network/sandbox";
1
+ import { AgentInteractiveSessionAttachSchema, AgentInteractiveSessionControlClaimSchema, AgentInteractiveSessionControlClaimAcknowledgementSchema, AgentInteractiveSessionControlClaimRequestSchema, AgentInteractiveSessionPromptAcknowledgementSchema, AgentInteractiveSessionPromptCommandSchema, AgentInteractiveSessionRefSchema, AgentInteractiveSessionStatusSchema, AgentInteractiveSessionStopAcknowledgementSchema, AgentInteractiveSessionStopCommandSchema, TerminalReplayWindowSchema, TerminalSessionRefSchema, agentInteractiveSessionControlClaimAcknowledgementMatchesRequest, agentInteractiveSessionControlClaimMatchesRef, agentInteractiveSessionPromptAcknowledgementMatchesCommand, agentInteractiveSessionRefMatchesStart, agentInteractiveSessionStatusMatchesRef, agentInteractiveSessionStopAcknowledgementMatchesCommand, canonicalCandidateDigest, exactAgentInteractiveSessionStart, } from "@tangle-network/agent-interface";
3
2
  import { assertOptionKeys } from "./tangle-environment-validation.js";
4
3
  import { transportFailureReason } from "./tangle-failure-reason.js";
5
- import { bindTangleInteractiveControl, closeTangleStreamQuietly, createTangleTerminalStreamCapture, prepareTangleTerminalAttachment, } from "./tangle-terminal.js";
6
4
  /**
7
5
  * Test the linked SDK surface without accepting any older interactive shape.
8
6
  *
9
- * The current public Sandbox release fails this test. That is intentional:
10
- * it has no receipt, control claim, or mutation acknowledgement protocol.
7
+ * Sandbox owns the exact reference, control claim, terminal binding, and
8
+ * mutation acknowledgements. An older handle shape fails this test closed.
11
9
  */
12
10
  export function sandboxBacksInteractiveAgent(box) {
13
11
  if (typeof box.session !== "function")
14
12
  return false;
15
13
  try {
16
- const candidate = box.session("__tangle-interactive-probe__").interactive?.();
17
- return (isSandboxInteractiveSession(candidate) &&
18
- typeof box.terminals?.get === "function");
14
+ const candidate = box
15
+ .session("__tangle-interactive-probe__")
16
+ .interactive?.();
17
+ return isSandboxInteractiveSession(candidate);
19
18
  }
20
19
  catch {
21
20
  return false;
@@ -29,9 +28,9 @@ export function sandboxBacksInteractiveAgent(box) {
29
28
  * never creates a second record for any of those facts.
30
29
  */
31
30
  export function createTangleInteractiveAgentRegistry(box, providerName, environmentId) {
32
- const exactHandle = (sessionId) => {
31
+ const exactHandle = (sessionId, binding) => {
33
32
  const session = box.session?.(sessionId);
34
- const candidate = session?.interactive?.();
33
+ const candidate = session?.interactive?.(binding);
35
34
  if (!isSandboxInteractiveSession(candidate)) {
36
35
  throw new Error("the linked Sandbox SDK does not expose the exact interactive control API");
37
36
  }
@@ -50,10 +49,13 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
50
49
  throw new Error("the Sandbox returned a different interactive session ref");
51
50
  }
52
51
  };
53
- const assertControl = (ref, control) => {
52
+ const assertControlMatchesRef = (ref, control) => {
54
53
  if (!agentInteractiveSessionControlClaimMatchesRef(ref, control)) {
55
54
  throw new Error("the interactive control claim belongs to another process");
56
55
  }
56
+ };
57
+ const assertActiveControl = (ref, control) => {
58
+ assertControlMatchesRef(ref, control);
57
59
  if (Date.parse(control.expiresAt) <= Date.now()) {
58
60
  throw new Error("the interactive control claim has expired");
59
61
  }
@@ -62,70 +64,68 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
62
64
  async start(request, options) {
63
65
  assertOptionKeys(options, ["signal"], "Tangle interactive agent start");
64
66
  const exactRequest = exactAgentInteractiveSessionStart(request);
65
- const requestedHarness = exactRequest.profile.harness;
66
- if (requestedHarness === undefined) {
67
+ if (exactRequest.profile.harness === undefined) {
67
68
  throw new Error("interactive agent sessions require AgentProfile.harness");
68
69
  }
69
- let harness;
70
- try {
71
- harness = parseBackendType(requestedHarness);
72
- }
73
- catch {
74
- throw new Error("the requested AgentProfile harness has no Tangle Sandbox runner");
75
- }
76
70
  options?.signal?.throwIfAborted();
77
71
  const handle = exactHandle(exactRequest.run.sessionId);
78
72
  let observed;
79
73
  try {
80
- observed = await handle.start({
81
- harness,
82
- ...(exactRequest.profile.model?.default === undefined
83
- ? {}
84
- : { model: exactRequest.profile.model.default }),
85
- ...(exactRequest.cwd === undefined ? {} : { cwd: exactRequest.cwd }),
86
- ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
87
- ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
88
- profile: exactRequest.profile,
89
- ...(exactRequest.initialPrompt === undefined
90
- ? {}
91
- : { initialPrompt: exactRequest.initialPrompt }),
92
- idempotencyKey: exactRequest.run.runId,
93
- requestDigest: exactRequest.run.requestDigest,
94
- });
74
+ observed = await handle.start(exactRequest, options);
95
75
  }
96
76
  catch (error) {
97
77
  options?.signal?.throwIfAborted();
98
78
  throw new Error(transportFailureReason("interactive start", error));
99
79
  }
100
80
  options?.signal?.throwIfAborted();
101
- const ref = refFromStart(exactRequest, observed);
81
+ const ref = AgentInteractiveSessionRefSchema.parse(observed.ref);
82
+ const control = AgentInteractiveSessionControlClaimSchema.parse(observed.control);
102
83
  if (!agentInteractiveSessionRefMatchesStart(exactRequest, ref)) {
103
84
  throw new Error("the Sandbox returned an interactive session with a different admission receipt");
104
85
  }
86
+ assertControlMatchesRef(ref, control);
87
+ if (observed.state === "running")
88
+ assertActiveControl(ref, control);
89
+ if (observed.state === "running" && observed.streamUrl.length === 0) {
90
+ throw new Error("the Sandbox returned a running session without a stream");
91
+ }
105
92
  return ref;
106
93
  },
107
94
  get(value) {
108
95
  const ref = exactRef(value);
109
- const handle = exactHandle(ref.run.sessionId);
96
+ const handle = exactHandle(ref.run.sessionId, { ref });
97
+ const mutationHandle = (control) => exactHandle(ref.run.sessionId, { ref, control });
110
98
  const readStatus = async (options) => {
111
99
  assertOptionKeys(options, ["signal"], "Tangle interactive agent status");
112
100
  options?.signal?.throwIfAborted();
113
101
  let observed;
114
102
  try {
115
- observed = await handle.status();
103
+ observed = await handle.status(options);
116
104
  }
117
105
  catch (error) {
118
106
  options?.signal?.throwIfAborted();
119
107
  throw new Error(transportFailureReason("interactive status", error));
120
108
  }
121
109
  options?.signal?.throwIfAborted();
122
- return statusFromSandbox(ref, observed);
110
+ if (observed === null) {
111
+ return AgentInteractiveSessionStatusSchema.parse({
112
+ state: "unknown",
113
+ ref,
114
+ message: "the Sandbox no longer knows this interactive agent session",
115
+ retryable: false,
116
+ });
117
+ }
118
+ const status = AgentInteractiveSessionStatusSchema.parse(observed);
119
+ if (!agentInteractiveSessionStatusMatchesRef(ref, status)) {
120
+ throw new Error("the Sandbox reported a different interactive agent session identity");
121
+ }
122
+ return status;
123
123
  };
124
124
  const validateControl = async (control, options) => {
125
- assertControl(ref, control);
125
+ assertActiveControl(ref, control);
126
126
  options?.signal?.throwIfAborted();
127
127
  try {
128
- await handle.validateControl(control);
128
+ await mutationHandle(control).validateControl(control, options);
129
129
  }
130
130
  catch (error) {
131
131
  options?.signal?.throwIfAborted();
@@ -142,7 +142,7 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
142
142
  options?.signal?.throwIfAborted();
143
143
  let raw;
144
144
  try {
145
- raw = await handle.claimControl(exactRequest);
145
+ raw = await handle.claimControl(exactRequest, options);
146
146
  }
147
147
  catch (error) {
148
148
  options?.signal?.throwIfAborted();
@@ -160,59 +160,33 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
160
160
  assertOptionKeys(options, ["signal"], "Tangle interactive agent attach");
161
161
  const exactRequest = AgentInteractiveSessionAttachSchema.parse(request ?? {});
162
162
  await validateControl(exactRequest.control, options);
163
- const before = await readStatus(options);
164
- if (before.state !== "running") {
165
- throw new Error("the exact interactive agent session is not running");
166
- }
167
- const terminals = box.terminals;
168
- if (terminals === undefined || typeof terminals.get !== "function") {
169
- throw new Error("the Sandbox client cannot read exact interactive terminal metadata");
170
- }
171
- const capture = createTangleTerminalStreamCapture(exactRequest);
172
- let stream;
163
+ let terminal;
173
164
  try {
174
- stream = await handle.attach({
175
- control: exactRequest.control,
176
- ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
177
- ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
178
- handlers: capture.handlers,
179
- });
165
+ terminal = await mutationHandle(exactRequest.control).attachAgentTerminal(exactRequest, options);
180
166
  }
181
167
  catch (error) {
182
168
  options?.signal?.throwIfAborted();
183
169
  throw new Error(transportFailureReason("interactive attach", error));
184
170
  }
185
- if (options?.signal?.aborted) {
186
- await closeTangleStreamQuietly(stream);
187
- options.signal.throwIfAborted();
188
- }
189
- const prepared = await prepareTangleTerminalAttachment({
190
- stream,
191
- capture,
192
- terminals,
193
- parentExecutionId: ref.run.executionId,
194
- expectedSessionId: ref.run.sessionId,
195
- signal: options?.signal,
196
- beforeMutation: (operation) => {
197
- if (operation === "detach")
198
- return Promise.resolve();
199
- return validateControl(exactRequest.control, options);
200
- },
201
- });
202
- if (prepared.status === "unknown") {
203
- throw new Error(prepared.message);
204
- }
205
- if (prepared.acknowledgement.restored !== true) {
206
- await prepared.session.detach();
207
- throw new Error("the exact interactive attach did not restore the coding-agent PTY");
171
+ try {
172
+ options?.signal?.throwIfAborted();
173
+ const returnedControl = AgentInteractiveSessionControlClaimSchema.parse(terminal.control);
174
+ const returnedRef = TerminalSessionRefSchema.parse(terminal.ref);
175
+ TerminalReplayWindowSchema.parse(terminal.cursors);
176
+ if (canonicalCandidateDigest(returnedControl) !==
177
+ canonicalCandidateDigest(exactRequest.control) ||
178
+ returnedRef.terminalSessionId !== ref.run.sessionId ||
179
+ returnedRef.parentExecutionId !== ref.run.executionId ||
180
+ returnedRef.expiresAt !== exactRequest.control.expiresAt ||
181
+ !returnedRef.isRunning) {
182
+ throw new Error("the Sandbox returned a terminal with a different interactive identity");
183
+ }
208
184
  }
209
- const after = await readStatus(options);
210
- if (after.state !== "running") {
211
- await prepared.session.detach();
212
- throw new Error("the exact interactive agent exited while its terminal attached");
185
+ catch (error) {
186
+ await terminal.detach().catch(() => undefined);
187
+ throw error;
213
188
  }
214
- const terminal = bindTangleInteractiveControl(prepared.session, exactRequest.control);
215
- return terminal;
189
+ return validatedTerminal(terminal, exactRequest.control, validateControl, ref);
216
190
  },
217
191
  async sendPrompt(command, options) {
218
192
  assertOptionKeys(options, ["signal"], "Tangle interactive agent prompt");
@@ -221,7 +195,7 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
221
195
  await validateControl(exactCommand.control, options);
222
196
  let raw;
223
197
  try {
224
- raw = await handle.sendPrompt(exactCommand);
198
+ raw = await mutationHandle(exactCommand.control).sendPrompt(exactCommand, options);
225
199
  }
226
200
  catch (error) {
227
201
  options?.signal?.throwIfAborted();
@@ -241,7 +215,7 @@ export function createTangleInteractiveAgentRegistry(box, providerName, environm
241
215
  await validateControl(exactCommand.control, options);
242
216
  let raw;
243
217
  try {
244
- raw = await handle.stop(exactCommand);
218
+ raw = await mutationHandle(exactCommand.control).stop(exactCommand, options);
245
219
  }
246
220
  catch (error) {
247
221
  options?.signal?.throwIfAborted();
@@ -266,62 +240,43 @@ function isSandboxInteractiveSession(value) {
266
240
  "start",
267
241
  "claimControl",
268
242
  "status",
269
- "attach",
243
+ "attachAgentTerminal",
270
244
  "validateControl",
271
245
  "sendPrompt",
272
246
  "stop",
273
247
  ].every((key) => typeof candidate[key] === "function");
274
248
  }
275
- function refFromStart(request, observed) {
276
- if (observed.sessionId !== request.run.sessionId ||
277
- observed.harness !== request.profile.harness) {
278
- throw new Error("the Sandbox returned a different interactive session identity");
279
- }
280
- return AgentInteractiveSessionRefSchema.parse({
281
- run: request.run,
282
- preparationReceipt: observed.preparationReceipt,
283
- incarnationId: observed.incarnationId,
284
- startedAt: observed.startedAt,
285
- });
286
- }
287
- function statusFromSandbox(ref, observed) {
288
- if (observed === null) {
289
- return AgentInteractiveSessionStatusSchema.parse({
290
- state: "unknown",
291
- ref,
292
- message: "the Sandbox no longer knows this interactive agent session",
293
- retryable: false,
294
- });
295
- }
296
- const observedRef = AgentInteractiveSessionRefSchema.parse({
297
- run: { ...ref.run, sessionId: observed.sessionId },
298
- preparationReceipt: observed.preparationReceipt,
299
- incarnationId: observed.incarnationId,
300
- startedAt: observed.startedAt,
301
- });
302
- if (observed.harness !== ref.preparationReceipt.harness ||
303
- !sameExactSessionRef(ref, observedRef)) {
304
- throw new Error("the Sandbox reported a different interactive agent session identity");
305
- }
306
- if (observed.state === "running") {
307
- if (observed.streamUrl.length === 0) {
308
- throw new Error("the Sandbox reported a running session without a stream");
309
- }
310
- return AgentInteractiveSessionStatusSchema.parse({ state: "running", ref });
311
- }
312
- return AgentInteractiveSessionStatusSchema.parse({
313
- state: "exited",
314
- ref,
315
- endedAt: observed.endedAt,
316
- reason: observed.reason,
317
- ...(Number.isSafeInteger(observed.exitCode)
318
- ? { exitCode: observed.exitCode }
319
- : {}),
320
- ...(typeof observed.exitSignal === "string" && observed.exitSignal.length > 0
321
- ? { exitSignal: observed.exitSignal }
322
- : {}),
323
- });
249
+ function validatedTerminal(terminal, control, validate, expectedRef) {
250
+ const boundControl = Object.freeze({ ...control });
251
+ return {
252
+ get ref() {
253
+ const ref = TerminalSessionRefSchema.parse(terminal.ref);
254
+ if (ref.terminalSessionId !== expectedRef.run.sessionId ||
255
+ ref.parentExecutionId !== expectedRef.run.executionId) {
256
+ throw new Error("the Sandbox terminal changed its interactive session identity");
257
+ }
258
+ return ref;
259
+ },
260
+ get cursors() {
261
+ return TerminalReplayWindowSchema.parse(terminal.cursors);
262
+ },
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
+ },
272
+ detach: (options) => terminal.detach(options),
273
+ async close(options) {
274
+ await validate(boundControl, options);
275
+ return terminal.close(options);
276
+ },
277
+ events: (options) => terminal.events(options),
278
+ };
324
279
  }
325
280
  function sameExactSessionRef(expected, candidate) {
326
- return canonicalCandidateDigest(expected) === canonicalCandidateDigest(candidate);
281
+ return (canonicalCandidateDigest(expected) === canonicalCandidateDigest(candidate));
327
282
  }