@tangle-network/agent-provider-tangle 1.0.1 → 1.1.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 +76 -8
- package/dist/exact-process.d.ts +2 -0
- package/dist/exact-process.js +13 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/tangle-capabilities.js +7 -0
- package/dist/tangle-create-options.js +56 -1
- package/dist/tangle-environment-control.js +8 -0
- package/dist/tangle-environment.d.ts +4 -2
- package/dist/tangle-environment.js +4 -2
- package/dist/tangle-prompt.d.ts +66 -2
- package/dist/tangle-prompt.js +292 -8
- package/dist/tangle-provider.js +14 -0
- package/dist/tangle-readiness.d.ts +43 -0
- package/dist/tangle-readiness.js +100 -0
- package/dist/tangle-types.d.ts +24 -0
- package/dist/tangle-workspace-branching.js +71 -52
- package/package.json +28 -17
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @tangle-network/agent-provider-tangle
|
|
2
2
|
|
|
3
3
|
Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
|
|
4
|
-
The peer range is `>=0.34.
|
|
5
|
-
The floor is 0.34.
|
|
4
|
+
The peer range is `>=0.34.6 <1.0.0`, and this package is developed and tested against 0.34.6.
|
|
5
|
+
The floor is 0.34.6 because exact interactive attachment needs the host receiver, and workspace branching needs keyed snapshots, durable restores, inventory recovery, and cleanup.
|
|
6
6
|
The provider fails closed when the configured backend or its catalog entry cannot be read.
|
|
7
7
|
Newer SDKs may also provide `getBackend()` as a lookup over the same catalog.
|
|
8
8
|
|
|
@@ -15,6 +15,75 @@ const provider = createTangleProvider({
|
|
|
15
15
|
});
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
## `create()` returns a ready environment
|
|
19
|
+
|
|
20
|
+
`provider.create()` does not return until the sandbox reports `running`.
|
|
21
|
+
This is the contract `AgentEnvironmentProvider.create` states, and every runtime seam relies on it: a caller streams the first turn immediately after create, with nothing in between.
|
|
22
|
+
A sandbox that never reaches running fails the create call with the platform's reason, and the adapter deletes the sandbox it could not hand over.
|
|
23
|
+
|
|
24
|
+
The gap this closes is narrow and specific.
|
|
25
|
+
`client.create()` waits by itself only when the create response reports `pending` or `provisioning`.
|
|
26
|
+
A response that already reports `running` skips that wait, and `running` alone is not usable: the SDK's own `waitFor` treats the target as reached only when `filesystemIncarnationReadiness` is `ready`, because the sandbox filesystem is still being built until then.
|
|
27
|
+
So `create()` can return a sandbox that reports `running` while the platform still holds a lifecycle operation on it, and the first turn lands on that lock.
|
|
28
|
+
|
|
29
|
+
Composing an environment also reads the sandbox's deployment capability document once, and a sandbox that is not yet running cannot answer that read, so an environment composed during provisioning would claim nothing for the rest of its life.
|
|
30
|
+
|
|
31
|
+
`readyTimeoutMs` bounds the wait and defaults to `DEFAULT_TANGLE_READY_TIMEOUT_MS` (120 seconds, the Sandbox SDK's own default).
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const provider = createTangleProvider({
|
|
35
|
+
client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
|
|
36
|
+
readyTimeoutMs: 180_000,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const environment = await provider.create({ profile: { name: "worker" } });
|
|
40
|
+
for await (const event of environment.stream({ prompt: "run the task" })) {
|
|
41
|
+
// The sandbox is running. No caller-side wait, poll, or retry stands here.
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The platform owns the wait, and this adapter runs no status loop of its own.
|
|
46
|
+
It calls `waitFor("running")` on the created instance when the linked SDK offers it, because that refreshes the created instance in place and keeps its create receipt.
|
|
47
|
+
It falls back to `client.waitForRunning(id)`, then refreshes the created instance.
|
|
48
|
+
A client that offers neither cannot prove readiness, so the sandbox it returned has to report `running` by itself or the create call fails.
|
|
49
|
+
|
|
50
|
+
The adapter reads the platform's answer back rather than trusting the wait to have resolved for the right reason.
|
|
51
|
+
A create call returns only a sandbox that reports `running`: `failed`, `stopped`, and `expired` are refused with the status named, and so is a wait that resolves while the sandbox still reports `provisioning`.
|
|
52
|
+
|
|
53
|
+
## Per-turn backend options
|
|
54
|
+
|
|
55
|
+
`AgentTurnInput.providerOptions.backend` reaches `PromptOptions.backend` on the Sandbox prompt call.
|
|
56
|
+
This is the exact block agent-runtime emits for a per-turn backend or model override, so a turn can select its model, its inline profile, or a session credential bundle without any change to the environment.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
await environment.stream({
|
|
60
|
+
prompt: "run the task",
|
|
61
|
+
providerOptions: {
|
|
62
|
+
backend: {
|
|
63
|
+
type: "opencode",
|
|
64
|
+
model: {
|
|
65
|
+
provider: "zai",
|
|
66
|
+
model: "glm-5.2",
|
|
67
|
+
authMode: "oauth",
|
|
68
|
+
authFiles: [{ path: ".config/opencode/auth.json", content: seatCredentials, mode: 0o600 }],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A field the Sandbox prompt options do not declare is refused, in `providerOptions`, in `backend`, and in `backend.model`.
|
|
76
|
+
The SDK drops what it does not declare, so a forwarded unknown field would run the turn on different settings with no error anywhere.
|
|
77
|
+
The accepted field sets are pinned to the SDK's `BackendConfig` at compile time, at all three levels including `authFiles`, so a field the SDK adds or removes fails this package's build instead of reaching a caller as a wrong refusal.
|
|
78
|
+
Values are checked, not just field names: an inline `backend.profile` is read by `agentProfileSchema`, the package that owns profile rules, and `backend.metadata.traceAttributes` is held to the entry and length limits the Sandbox platform states for it.
|
|
79
|
+
`AgentTurnInput.interactions` and `backend.interactions` state the same posture, and a disagreement between them is refused rather than resolved by preference.
|
|
80
|
+
A turn `model` that disagrees with `backend.model.model` is refused for the same reason.
|
|
81
|
+
`AgentTurnInput.interactions` still maps to `backend.interactions` for a turn that carries no backend block at all.
|
|
82
|
+
|
|
83
|
+
Backend options are part of the retained request digest, so a retry under the same `turnId` with a changed model, profile, or seat conflicts instead of replaying work that ran on other settings.
|
|
84
|
+
Bearer material is excluded from that identity: `model.apiKey` is dropped and `authFiles` are reduced to the paths and modes they install.
|
|
85
|
+
A rotated seat token is the same seat running the same work, so an ordinary refresh continues its run rather than conflicting with it.
|
|
86
|
+
|
|
18
87
|
Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`.
|
|
19
88
|
The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled.
|
|
20
89
|
Created and reconstructed environments expose a recursively frozen Sandbox metadata snapshot for constant-time annotation checks.
|
|
@@ -22,8 +91,6 @@ Sandbox metadata can include caller-authored values and does not authenticate it
|
|
|
22
91
|
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.
|
|
23
92
|
Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session.
|
|
24
93
|
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`.
|
|
25
|
-
`AgentTurnInput.interactions` maps unchanged to `PromptOptions.backend.interactions` for the selected Sandbox turn.
|
|
26
|
-
The requested posture is part of the retained request digest, so a retry with changed interaction behavior conflicts instead of reusing prior work.
|
|
27
94
|
|
|
28
95
|
## Two capability documents
|
|
29
96
|
|
|
@@ -112,7 +179,7 @@ and recovering a checkpoint, forking one managed child, and cleaning both
|
|
|
112
179
|
resources in dependency order.
|
|
113
180
|
The adapter advertises `branching.checkpoint`, `branching.fork`, `retrySafe`,
|
|
114
181
|
`lookup`, and `cleanup` only when the linked SDK exposes the complete managed
|
|
115
|
-
surface: keyed `snapshot
|
|
182
|
+
surface: keyed `snapshot`, durable snapshot restore, inventory recovery, and
|
|
116
183
|
explicit deletion outcomes.
|
|
117
184
|
An incomplete SDK surface clears every branching flag and omits
|
|
118
185
|
`environment.workspaceBranching`.
|
|
@@ -122,9 +189,10 @@ calling Sandbox.
|
|
|
122
189
|
Retries with the same key replay the original resource, while changed material
|
|
123
190
|
returns a conflict containing the original interface digest.
|
|
124
191
|
The provider stores a bounded request marker in snapshot tags and child
|
|
125
|
-
metadata so a fresh process can recover the exact interface digest
|
|
126
|
-
|
|
127
|
-
|
|
192
|
+
metadata so a fresh process can recover the exact interface digest.
|
|
193
|
+
Snapshot-created children are validated through the marker and complete
|
|
194
|
+
account inventory, while legacy fork markers also require the Sandbox fork
|
|
195
|
+
ledger to report a settled success.
|
|
128
196
|
Checkpoint deletion reports `in_use` with every verified child that still
|
|
129
197
|
references it; delete the child first, then retry checkpoint deletion.
|
|
130
198
|
The adapter never treats an SDK response without an explicit idempotency or
|
package/dist/exact-process.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export declare function createTangleExactProcessProvider(input: {
|
|
|
4
4
|
client: SandboxClientLike;
|
|
5
5
|
options: TangleExactProcessOptions;
|
|
6
6
|
providerName: string;
|
|
7
|
+
/** How long create() waits for the new sandbox to reach `running`. */
|
|
8
|
+
readyTimeoutMs: number;
|
|
7
9
|
}): AgentExactProcessProvider;
|
package/dist/exact-process.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { assertBoundedJson, attachCleanupHandle, awaitWithSignal, boundedIdentifier, boundedString, exactProcessRequestDigest, isBoundedJson, MAX_LIST_RESULTS, SANDBOX_LIST_PAGE_SIZE, } from "./tangle-contract-safety.js";
|
|
2
2
|
import { sandboxInstanceAsExactProcessEnvironment } from "./tangle-exact-process-environment.js";
|
|
3
|
+
import { awaitSandboxRunning } from "./tangle-readiness.js";
|
|
3
4
|
import { assertExactProcessSandbox, assertSupportedProviderOptions, assertUnreservedMetadata, EXACT_PROCESS_METADATA_KEY, isExactProcessRequestConflict, isExactProcessSandbox, metadataMatches, assertSignalOptions, } from "./tangle-exact-process-validation.js";
|
|
4
5
|
const IMMUTABLE_TANGLE_IMAGE = /^(?:sha256:[a-f0-9]{64}|\S+@sha256:[a-f0-9]{64})$/i;
|
|
5
6
|
export function createTangleExactProcessProvider(input) {
|
|
6
|
-
const { client, options, providerName } = input;
|
|
7
|
+
const { client, options, providerName, readyTimeoutMs } = input;
|
|
7
8
|
boundedIdentifier(providerName, "Tangle exact process provider");
|
|
8
9
|
if (options.teamId !== undefined) {
|
|
9
10
|
boundedIdentifier(options.teamId, "Tangle exact process team id");
|
|
@@ -51,7 +52,18 @@ export function createTangleExactProcessProvider(input) {
|
|
|
51
52
|
}
|
|
52
53
|
try {
|
|
53
54
|
createInput.signal?.throwIfAborted();
|
|
55
|
+
// Ownership is decided from metadata this call already holds, so it is
|
|
56
|
+
// decided before the wait. A foreign sandbox returned under a reused
|
|
57
|
+
// idempotency key is a conflict now, not two minutes from now.
|
|
54
58
|
assertExactProcessSandbox(box, providerName, options.teamId, identityDigest);
|
|
59
|
+
// A launch starts a process on this sandbox, so create() returns only
|
|
60
|
+
// after the sandbox can run one. The caller's provisioning budget owns
|
|
61
|
+
// the deadline when it named one.
|
|
62
|
+
await awaitSandboxRunning(box, client, {
|
|
63
|
+
timeoutMs: createInput.provisionTimeoutMs ?? readyTimeoutMs,
|
|
64
|
+
...(createInput.signal ? { signal: createInput.signal } : {}),
|
|
65
|
+
});
|
|
66
|
+
createInput.signal?.throwIfAborted();
|
|
55
67
|
return sandboxInstanceAsExactProcessEnvironment(box, providerName);
|
|
56
68
|
}
|
|
57
69
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { TangleExactProcessOptions } from "./tangle-types.js";
|
|
2
2
|
export * from "./tangle-types.js";
|
|
3
3
|
export { createTangleProvider } from "./tangle-provider.js";
|
|
4
|
+
export { DEFAULT_TANGLE_READY_TIMEOUT_MS } from "./tangle-readiness.js";
|
|
4
5
|
export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
|
|
5
6
|
export { createTangleWorkspaceBranching, supportsWorkspaceBranching, } from "./tangle-workspace-branching.js";
|
|
6
7
|
export type { TangleWorkspaceBranchingOptions } from "./tangle-workspace-branching.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./tangle-types.js";
|
|
2
2
|
export { createTangleProvider } from "./tangle-provider.js";
|
|
3
|
+
export { DEFAULT_TANGLE_READY_TIMEOUT_MS } from "./tangle-readiness.js";
|
|
3
4
|
export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
|
|
4
5
|
export { createTangleWorkspaceBranching, supportsWorkspaceBranching, } from "./tangle-workspace-branching.js";
|
|
5
6
|
export { decodeTangleConfidentialAttestationQuote, encodeTangleConfidentialAttestationQuote, MAX_TEE_EVIDENCE_BYTES, MAX_TEE_MEASUREMENT_BYTES, TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND, TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION, } from "./tangle-confidential-attestation.js";
|
|
@@ -92,6 +92,13 @@ export function defaultTangleSandboxCapabilities(harness) {
|
|
|
92
92
|
},
|
|
93
93
|
placement: true,
|
|
94
94
|
usage: false,
|
|
95
|
+
// Create carries both fields to the Sandbox API unchanged. The API still authorizes the
|
|
96
|
+
// caller for a delegated billing owner, and it rejects the create when it does not, so this
|
|
97
|
+
// states only that neither field is dropped on the way.
|
|
98
|
+
create: {
|
|
99
|
+
egress: ["open", "strict", "blocked"],
|
|
100
|
+
billingOwner: true,
|
|
101
|
+
},
|
|
95
102
|
// This is intent only. Narrowing requires both raw TEE evidence and the
|
|
96
103
|
// caller's external provider-key verifier before the flag survives.
|
|
97
104
|
confidential: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface/environment-provider";
|
|
1
|
+
import { AgentEnvironmentEgressPolicySchema, WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface/environment-provider";
|
|
2
2
|
import { assertBoundedJson, boundedIdentifier, boundedString, MAX_ARRAY_LENGTH, MAX_MAP_ENTRIES, } from "./tangle-contract-safety.js";
|
|
3
3
|
import { sandboxResourcesFromResourceRequest } from "./tangle-resources.js";
|
|
4
4
|
export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorkspace) {
|
|
@@ -32,6 +32,9 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorks
|
|
|
32
32
|
if (input.idempotencyKey !== undefined) {
|
|
33
33
|
boundedIdentifier(input.idempotencyKey, "Tangle idempotency key");
|
|
34
34
|
}
|
|
35
|
+
if (input.billingOwner !== undefined) {
|
|
36
|
+
boundedIdentifier(input.billingOwner, "Tangle billing owner");
|
|
37
|
+
}
|
|
35
38
|
const resources = sandboxResourcesFromResourceRequest(input.resources);
|
|
36
39
|
// Sandbox injects secrets by name from its own store. Accepting a name/value
|
|
37
40
|
// record and dropping it would create an environment with no credentials and
|
|
@@ -53,6 +56,8 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorks
|
|
|
53
56
|
...(resources ? { resources } : {}),
|
|
54
57
|
...(input.env ? { env: input.env } : {}),
|
|
55
58
|
...(Array.isArray(input.secrets) ? { secrets: input.secrets } : {}),
|
|
59
|
+
...(input.egress === undefined ? {} : { egressPolicy: sandboxEgressPolicy(input.egress) }),
|
|
60
|
+
...(input.billingOwner === undefined ? {} : { billingOwnerId: input.billingOwner }),
|
|
56
61
|
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
57
62
|
...(input.name === undefined ? {} : { name: input.name }),
|
|
58
63
|
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
|
@@ -64,6 +69,27 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorks
|
|
|
64
69
|
};
|
|
65
70
|
return mapped;
|
|
66
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Project the portable egress policy onto the Sandbox policy.
|
|
74
|
+
*
|
|
75
|
+
* The schema is a strict discriminated union, so a domain list outside `strict` is refused rather
|
|
76
|
+
* than sent: Sandbox IGNORES `allowDomains` in `open` and `blocked` mode, and a silently ignored
|
|
77
|
+
* allowlist is a policy the caller believes is in force and is not.
|
|
78
|
+
*
|
|
79
|
+
* `includeImplicitDomains` stays unset, which is the Sandbox default of false. A strict policy
|
|
80
|
+
* therefore reaches the named domains plus the model endpoints the platform provisioned, matching
|
|
81
|
+
* what {@link AgentEnvironmentEgressPolicy} states; opting in would silently add ~40 hosts,
|
|
82
|
+
* including public source hosts.
|
|
83
|
+
*/
|
|
84
|
+
function sandboxEgressPolicy(policy) {
|
|
85
|
+
const parsed = AgentEnvironmentEgressPolicySchema.parse(policy);
|
|
86
|
+
if (parsed.mode !== "strict")
|
|
87
|
+
return { mode: parsed.mode };
|
|
88
|
+
return {
|
|
89
|
+
mode: "strict",
|
|
90
|
+
...(parsed.allowDomains === undefined ? {} : { allowDomains: [...parsed.allowDomains] }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
67
93
|
/** Reject value-bearing secret maps before any custom mapper can drop them. */
|
|
68
94
|
export function assertNoInlineSecretValues(input, parsedWorkspace) {
|
|
69
95
|
if (input.providerOptions !== undefined) {
|
|
@@ -126,6 +152,8 @@ export function assertCreateInputShape(input, parsedWorkspace) {
|
|
|
126
152
|
"resources",
|
|
127
153
|
"env",
|
|
128
154
|
"secrets",
|
|
155
|
+
"egress",
|
|
156
|
+
"billingOwner",
|
|
129
157
|
"metadata",
|
|
130
158
|
"name",
|
|
131
159
|
"idempotencyKey",
|
|
@@ -175,6 +203,33 @@ export function assertMappedCreateOptions(options) {
|
|
|
175
203
|
}
|
|
176
204
|
if (options.idempotencyKey !== undefined)
|
|
177
205
|
boundedIdentifier(options.idempotencyKey, "Tangle mapped idempotency key");
|
|
206
|
+
if (options.billingOwnerId !== undefined)
|
|
207
|
+
boundedIdentifier(options.billingOwnerId, "Tangle mapped billing owner");
|
|
208
|
+
if (options.egressPolicy !== undefined) {
|
|
209
|
+
if (!options.egressPolicy || typeof options.egressPolicy !== "object" || Array.isArray(options.egressPolicy)) {
|
|
210
|
+
throw new Error("Tangle mapped egress policy must be an object");
|
|
211
|
+
}
|
|
212
|
+
if (!["open", "strict", "blocked"].includes(options.egressPolicy.mode)) {
|
|
213
|
+
throw new Error("Tangle mapped egress policy mode is invalid");
|
|
214
|
+
}
|
|
215
|
+
if (options.egressPolicy.mode !== "strict" && options.egressPolicy.allowDomains !== undefined) {
|
|
216
|
+
throw new Error("Tangle mapped egress policy allows domains only in strict mode");
|
|
217
|
+
}
|
|
218
|
+
// The default path is schema-checked, so this gate holds a custom mapper to the same shape.
|
|
219
|
+
// A non-string or padded host reaches the platform, matches nothing, and leaves the caller
|
|
220
|
+
// believing an allowlist is in force that is not.
|
|
221
|
+
if (options.egressPolicy.allowDomains !== undefined) {
|
|
222
|
+
if (!Array.isArray(options.egressPolicy.allowDomains)) {
|
|
223
|
+
throw new Error("Tangle mapped egress allowed domains must be an array");
|
|
224
|
+
}
|
|
225
|
+
if (options.egressPolicy.allowDomains.length > MAX_ARRAY_LENGTH) {
|
|
226
|
+
throw new Error("Tangle mapped egress allowed domains exceed their bound");
|
|
227
|
+
}
|
|
228
|
+
for (const domain of options.egressPolicy.allowDomains) {
|
|
229
|
+
boundedIdentifier(domain, "Tangle mapped egress allowed domain");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
178
233
|
if (options.env !== undefined)
|
|
179
234
|
assertStringRecord(options.env, "Tangle mapped");
|
|
180
235
|
assertMappedSecretNames(options);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { canonicalCandidateDigest } from "@tangle-network/agent-interface";
|
|
2
|
+
import { backendFromTurnProviderOptions, backendRequestIdentity, } from "./tangle-prompt.js";
|
|
2
3
|
export function sessionPromptRequestDigest(input, provider, environmentId, sessionId, options = {}) {
|
|
4
|
+
const backend = backendRequestIdentity(backendFromTurnProviderOptions(input.providerOptions));
|
|
3
5
|
return canonicalCandidateDigest({
|
|
4
6
|
provider,
|
|
5
7
|
environmentId,
|
|
@@ -18,6 +20,12 @@ export function sessionPromptRequestDigest(input, provider, environmentId, sessi
|
|
|
18
20
|
...(input.interactions === undefined
|
|
19
21
|
? {}
|
|
20
22
|
: { interactions: input.interactions }),
|
|
23
|
+
// Per-turn backend options select the model, the profile, and the seat the
|
|
24
|
+
// turn runs on. Leaving them out of the identity would let a retry under
|
|
25
|
+
// the same turn id reuse work that ran on other settings. The bearer
|
|
26
|
+
// material inside them is excluded, so a token refresh does not conflict
|
|
27
|
+
// with the run it continues.
|
|
28
|
+
...(backend === undefined ? {} : { backend }),
|
|
21
29
|
});
|
|
22
30
|
}
|
|
23
31
|
export function hasReplayPayload(input) {
|
|
@@ -15,8 +15,10 @@ import type { TangleConfidentialAttestationVerifier } from "./tangle-types.js";
|
|
|
15
15
|
* The document is measured once, here. A sandbox that is not yet running
|
|
16
16
|
* cannot answer, so an environment composed during provisioning claims
|
|
17
17
|
* nothing and keeps claiming nothing: the exposed operations and the document
|
|
18
|
-
* are composed together and a caller may already hold either one.
|
|
19
|
-
*
|
|
18
|
+
* are composed together and a caller may already hold either one. This is why
|
|
19
|
+
* `provider.create()` holds until the sandbox is running before it composes
|
|
20
|
+
* (see `tangle-readiness.ts`). `provider.get(id)` composes whatever the
|
|
21
|
+
* sandbox is at that moment, so compose again once a starting sandbox runs.
|
|
20
22
|
*
|
|
21
23
|
* @param request What the create call asked for. An environment rebuilt by id
|
|
22
24
|
* carries none of it, so its observation reports the requested compute shape
|
|
@@ -32,8 +32,10 @@ import { confidentialVerifierOption, createTangleWorkspaceBranching, } from "./t
|
|
|
32
32
|
* The document is measured once, here. A sandbox that is not yet running
|
|
33
33
|
* cannot answer, so an environment composed during provisioning claims
|
|
34
34
|
* nothing and keeps claiming nothing: the exposed operations and the document
|
|
35
|
-
* are composed together and a caller may already hold either one.
|
|
36
|
-
*
|
|
35
|
+
* are composed together and a caller may already hold either one. This is why
|
|
36
|
+
* `provider.create()` holds until the sandbox is running before it composes
|
|
37
|
+
* (see `tangle-readiness.ts`). `provider.get(id)` composes whatever the
|
|
38
|
+
* sandbox is at that moment, so compose again once a starting sandbox runs.
|
|
37
39
|
*
|
|
38
40
|
* @param request What the create call asked for. An environment rebuilt by id
|
|
39
41
|
* carries none of it, so its observation reports the requested compute shape
|
package/dist/tangle-prompt.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PromptOptions, PromptResult } from "@tangle-network/sandbox";
|
|
1
|
+
import type { BackendConfig, PromptOptions, PromptResult } from "@tangle-network/sandbox";
|
|
2
2
|
import type { AgentTurnInput, AgentTurnResult } from "@tangle-network/agent-interface/environment-provider";
|
|
3
3
|
import type { AgentExactRunControlRef, InputPart } from "@tangle-network/agent-interface";
|
|
4
4
|
export declare function promptFromTurnInput(input: AgentTurnInput): string | InputPart[];
|
|
@@ -8,6 +8,71 @@ export declare function promptOptionsFromTurnInput(input: AgentTurnInput, target
|
|
|
8
8
|
environmentId: string;
|
|
9
9
|
sessionId?: string;
|
|
10
10
|
}): PromptOptions;
|
|
11
|
+
/**
|
|
12
|
+
* The `BackendConfig` fields the Sandbox prompt options declare.
|
|
13
|
+
*
|
|
14
|
+
* A turn may carry any of them and nothing else. An undeclared field is
|
|
15
|
+
* refused instead of forwarded, because the SDK drops what it does not
|
|
16
|
+
* declare: the turn would then run on different settings with no error
|
|
17
|
+
* anywhere, which is the silent substitution the SDK's own `model` field
|
|
18
|
+
* exists to prevent.
|
|
19
|
+
*/
|
|
20
|
+
declare const SANDBOX_BACKEND_FIELD_LIST: readonly ["type", "profile", "model", "server", "interactions", "metadata"];
|
|
21
|
+
/**
|
|
22
|
+
* The `BackendConfig["model"]` fields the Sandbox prompt options declare.
|
|
23
|
+
*
|
|
24
|
+
* This is the block that carries per-turn credentials — `authMode: "oauth"`
|
|
25
|
+
* with `authFiles` for a subscription seat — so it is checked field by field
|
|
26
|
+
* rather than passed through as opaque JSON.
|
|
27
|
+
*/
|
|
28
|
+
declare const SANDBOX_BACKEND_MODEL_FIELD_LIST: readonly ["provider", "model", "apiKey", "baseUrl", "maxThinkingTokens", "mode", "apiKeyEnv", "authMode", "authFiles"];
|
|
29
|
+
/**
|
|
30
|
+
* The two lists above are the SDK's field sets, restated as values because a
|
|
31
|
+
* TypeScript type cannot be read at run time. This pin keeps them exact in
|
|
32
|
+
* both directions: a field the SDK adds, renames, or removes fails the build
|
|
33
|
+
* here rather than reaching a caller as a wrong refusal or a silent drop.
|
|
34
|
+
*/
|
|
35
|
+
type Exhaustive<T extends never> = T;
|
|
36
|
+
type SandboxBackendModel = NonNullable<BackendConfig["model"]>;
|
|
37
|
+
type UncoveredBackendField = Exhaustive<Exclude<keyof BackendConfig, (typeof SANDBOX_BACKEND_FIELD_LIST)[number]>>;
|
|
38
|
+
type StaleBackendField = Exhaustive<Exclude<(typeof SANDBOX_BACKEND_FIELD_LIST)[number], keyof BackendConfig>>;
|
|
39
|
+
type UncoveredBackendModelField = Exhaustive<Exclude<keyof SandboxBackendModel, (typeof SANDBOX_BACKEND_MODEL_FIELD_LIST)[number]>>;
|
|
40
|
+
type StaleBackendModelField = Exhaustive<Exclude<(typeof SANDBOX_BACKEND_MODEL_FIELD_LIST)[number], keyof SandboxBackendModel>>;
|
|
41
|
+
declare const SANDBOX_AUTH_FILE_FIELD_LIST: readonly ["path", "content", "mode"];
|
|
42
|
+
type SandboxAuthFile = NonNullable<SandboxBackendModel["authFiles"]>[number];
|
|
43
|
+
type UncoveredAuthFileField = Exhaustive<Exclude<keyof SandboxAuthFile, (typeof SANDBOX_AUTH_FILE_FIELD_LIST)[number]>>;
|
|
44
|
+
type StaleAuthFileField = Exhaustive<Exclude<(typeof SANDBOX_AUTH_FILE_FIELD_LIST)[number], keyof SandboxAuthFile>>;
|
|
45
|
+
type SandboxBackendFieldCoverage = [
|
|
46
|
+
UncoveredBackendField,
|
|
47
|
+
StaleBackendField,
|
|
48
|
+
UncoveredBackendModelField,
|
|
49
|
+
StaleBackendModelField,
|
|
50
|
+
UncoveredAuthFileField,
|
|
51
|
+
StaleAuthFileField
|
|
52
|
+
];
|
|
53
|
+
export type { SandboxBackendFieldCoverage };
|
|
54
|
+
type SandboxPromptBackend = NonNullable<PromptOptions["backend"]>;
|
|
55
|
+
/**
|
|
56
|
+
* Read the per-turn backend options a caller sent through `providerOptions`.
|
|
57
|
+
*
|
|
58
|
+
* `AgentTurnInput.providerOptions.backend` is the exact shape agent-runtime
|
|
59
|
+
* emits for a per-turn backend or model override, so refusing it dropped the
|
|
60
|
+
* caller's model, profile, and session credential bundle at the adapter
|
|
61
|
+
* boundary. Every field is checked against what the Sandbox prompt options
|
|
62
|
+
* declare, and the result is bounded JSON the SDK reads once.
|
|
63
|
+
*/
|
|
64
|
+
export declare function backendFromTurnProviderOptions(providerOptions: Record<string, unknown> | undefined): SandboxPromptBackend | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* The part of the backend options that decides which work a turn is.
|
|
67
|
+
*
|
|
68
|
+
* Bearer material is deliberately absent. A rotated seat token is the same
|
|
69
|
+
* seat running the same work, so digesting the token bytes would make an
|
|
70
|
+
* ordinary refresh conflict with the run it is continuing. The auth files are
|
|
71
|
+
* reduced to the paths and modes they install, which states that the turn
|
|
72
|
+
* carries a credential bundle and which slots it fills, without binding the
|
|
73
|
+
* identity to the secret inside.
|
|
74
|
+
*/
|
|
75
|
+
export declare function backendRequestIdentity(backend: SandboxPromptBackend | undefined): Record<string, unknown> | undefined;
|
|
11
76
|
type SandboxRunStatus = "success" | "failed" | "blocked_on_approval" | "awaiting_question" | "awaiting_interaction" | "awaiting_plan_decision";
|
|
12
77
|
type ValidatedSandboxPromptResult = Record<string, unknown> & {
|
|
13
78
|
success: boolean;
|
|
@@ -22,4 +87,3 @@ export declare function agentTurnResultFromPromptRecord(record: ValidatedSandbox
|
|
|
22
87
|
sessionId?: string;
|
|
23
88
|
controlRef?: AgentExactRunControlRef;
|
|
24
89
|
}): AgentTurnResult;
|
|
25
|
-
export {};
|
package/dist/tangle-prompt.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AgentExactRunControlRefSchema, AgentTurnInputSchema, ContextTransferReceiptSchema, contextTransferResultMatchesRequest, } from "@tangle-network/agent-interface";
|
|
1
|
+
import { agentProfileSchema, AgentExactRunControlRefSchema, AgentTurnInputSchema, ContextTransferReceiptSchema, contextTransferResultMatchesRequest, } from "@tangle-network/agent-interface";
|
|
2
2
|
import { tokenUsageFromData } from "./tangle-result-values.js";
|
|
3
|
-
import { assertBoundedJson } from "./tangle-contract-safety.js";
|
|
3
|
+
import { assertBoundedJson, boundedString } from "./tangle-contract-safety.js";
|
|
4
4
|
export function promptFromTurnInput(input) {
|
|
5
5
|
AgentTurnInputSchema.parse(input);
|
|
6
6
|
if (input.parts)
|
|
@@ -40,17 +40,13 @@ export function promptOptionsFromTurnInput(input, target) {
|
|
|
40
40
|
throw new Error("Tangle executionId conflicts with the control reference");
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
throw new Error("Tangle prompt providerOptions are not supported");
|
|
45
|
-
}
|
|
43
|
+
const backend = turnBackendOptions(input);
|
|
46
44
|
const sessionId = input.sessionId ?? controlRef?.sessionId;
|
|
47
45
|
const executionId = input.executionId ?? controlRef?.executionId;
|
|
48
46
|
return {
|
|
49
47
|
...(sessionId ? { sessionId } : {}),
|
|
50
48
|
...(input.model ? { model: input.model } : {}),
|
|
51
|
-
...(
|
|
52
|
-
? {}
|
|
53
|
-
: { backend: { interactions: input.interactions } }),
|
|
49
|
+
...(backend === undefined ? {} : { backend }),
|
|
54
50
|
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
55
51
|
...(input.context ? { context: input.context } : {}),
|
|
56
52
|
...(input.signal ? { signal: input.signal } : {}),
|
|
@@ -61,6 +57,294 @@ export function promptOptionsFromTurnInput(input, target) {
|
|
|
61
57
|
...(input.detach !== undefined ? { detach: input.detach } : {}),
|
|
62
58
|
};
|
|
63
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The `BackendConfig` fields the Sandbox prompt options declare.
|
|
62
|
+
*
|
|
63
|
+
* A turn may carry any of them and nothing else. An undeclared field is
|
|
64
|
+
* refused instead of forwarded, because the SDK drops what it does not
|
|
65
|
+
* declare: the turn would then run on different settings with no error
|
|
66
|
+
* anywhere, which is the silent substitution the SDK's own `model` field
|
|
67
|
+
* exists to prevent.
|
|
68
|
+
*/
|
|
69
|
+
const SANDBOX_BACKEND_FIELD_LIST = [
|
|
70
|
+
"type",
|
|
71
|
+
"profile",
|
|
72
|
+
"model",
|
|
73
|
+
"server",
|
|
74
|
+
"interactions",
|
|
75
|
+
"metadata",
|
|
76
|
+
];
|
|
77
|
+
const SANDBOX_BACKEND_FIELDS = new Set(SANDBOX_BACKEND_FIELD_LIST);
|
|
78
|
+
/**
|
|
79
|
+
* The `BackendConfig["model"]` fields the Sandbox prompt options declare.
|
|
80
|
+
*
|
|
81
|
+
* This is the block that carries per-turn credentials — `authMode: "oauth"`
|
|
82
|
+
* with `authFiles` for a subscription seat — so it is checked field by field
|
|
83
|
+
* rather than passed through as opaque JSON.
|
|
84
|
+
*/
|
|
85
|
+
const SANDBOX_BACKEND_MODEL_FIELD_LIST = [
|
|
86
|
+
"provider",
|
|
87
|
+
"model",
|
|
88
|
+
"apiKey",
|
|
89
|
+
"baseUrl",
|
|
90
|
+
"maxThinkingTokens",
|
|
91
|
+
"mode",
|
|
92
|
+
"apiKeyEnv",
|
|
93
|
+
"authMode",
|
|
94
|
+
"authFiles",
|
|
95
|
+
];
|
|
96
|
+
const SANDBOX_BACKEND_MODEL_FIELDS = new Set(SANDBOX_BACKEND_MODEL_FIELD_LIST);
|
|
97
|
+
const SANDBOX_AUTH_FILE_FIELD_LIST = ["path", "content", "mode"];
|
|
98
|
+
/**
|
|
99
|
+
* Read the per-turn backend options a caller sent through `providerOptions`.
|
|
100
|
+
*
|
|
101
|
+
* `AgentTurnInput.providerOptions.backend` is the exact shape agent-runtime
|
|
102
|
+
* emits for a per-turn backend or model override, so refusing it dropped the
|
|
103
|
+
* caller's model, profile, and session credential bundle at the adapter
|
|
104
|
+
* boundary. Every field is checked against what the Sandbox prompt options
|
|
105
|
+
* declare, and the result is bounded JSON the SDK reads once.
|
|
106
|
+
*/
|
|
107
|
+
export function backendFromTurnProviderOptions(providerOptions) {
|
|
108
|
+
if (providerOptions === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
assertDeclaredFields(providerOptions, new Set(["backend"]), "Tangle prompt providerOptions");
|
|
111
|
+
if (!Object.hasOwn(providerOptions, "backend"))
|
|
112
|
+
return undefined;
|
|
113
|
+
return sandboxPromptBackend(providerOptions.backend);
|
|
114
|
+
}
|
|
115
|
+
function sandboxPromptBackend(value) {
|
|
116
|
+
const present = plainRecord(value, "Tangle prompt backend options");
|
|
117
|
+
assertDeclaredFields(present, SANDBOX_BACKEND_FIELDS, "Tangle prompt backend options");
|
|
118
|
+
const backend = {
|
|
119
|
+
...(present.type === undefined
|
|
120
|
+
? {}
|
|
121
|
+
: { type: boundedString(present.type, "Tangle prompt backend type") }),
|
|
122
|
+
// The profile decides tools, permissions, mounts, and the security policy
|
|
123
|
+
// of the turn, so it is read by the schema that owns those rules rather
|
|
124
|
+
// than passed through as bounded JSON.
|
|
125
|
+
...(present.profile === undefined
|
|
126
|
+
? {}
|
|
127
|
+
: { profile: agentProfileSchema.parse(present.profile) }),
|
|
128
|
+
...(present.model === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: { model: sandboxPromptBackendModel(present.model) }),
|
|
131
|
+
...(present.server === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { server: sandboxPromptBackendServer(present.server) }),
|
|
134
|
+
...(present.interactions === undefined
|
|
135
|
+
? {}
|
|
136
|
+
: { interactions: sandboxPromptInteractions(present.interactions) }),
|
|
137
|
+
...(present.metadata === undefined
|
|
138
|
+
? {}
|
|
139
|
+
: { metadata: sandboxPromptBackendMetadata(present.metadata) }),
|
|
140
|
+
};
|
|
141
|
+
assertBoundedJson(backend);
|
|
142
|
+
return backend;
|
|
143
|
+
}
|
|
144
|
+
function sandboxPromptBackendModel(value) {
|
|
145
|
+
const present = plainRecord(value, "Tangle prompt backend model options");
|
|
146
|
+
assertDeclaredFields(present, SANDBOX_BACKEND_MODEL_FIELDS, "Tangle prompt backend model options");
|
|
147
|
+
const model = {};
|
|
148
|
+
for (const field of ["provider", "model", "apiKey", "baseUrl", "apiKeyEnv"]) {
|
|
149
|
+
if (present[field] === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
if (typeof present[field] !== "string" || present[field] === "") {
|
|
152
|
+
throw new Error(`Tangle prompt backend model ${field} must be a non-empty string`);
|
|
153
|
+
}
|
|
154
|
+
model[field] = boundedString(present[field], `Tangle prompt backend model ${field}`);
|
|
155
|
+
}
|
|
156
|
+
if (present.maxThinkingTokens !== undefined) {
|
|
157
|
+
if (!Number.isSafeInteger(present.maxThinkingTokens) ||
|
|
158
|
+
present.maxThinkingTokens < 0) {
|
|
159
|
+
throw new Error("Tangle prompt backend model maxThinkingTokens must be a non-negative integer");
|
|
160
|
+
}
|
|
161
|
+
model.maxThinkingTokens = present.maxThinkingTokens;
|
|
162
|
+
}
|
|
163
|
+
if (present.mode !== undefined) {
|
|
164
|
+
if (present.mode !== "api" && present.mode !== "cli") {
|
|
165
|
+
throw new Error('Tangle prompt backend model mode must be "api" or "cli"');
|
|
166
|
+
}
|
|
167
|
+
model.mode = present.mode;
|
|
168
|
+
}
|
|
169
|
+
if (present.authMode !== undefined) {
|
|
170
|
+
if (present.authMode !== "api-key" && present.authMode !== "oauth") {
|
|
171
|
+
throw new Error('Tangle prompt backend model authMode must be "api-key" or "oauth"');
|
|
172
|
+
}
|
|
173
|
+
model.authMode = present.authMode;
|
|
174
|
+
}
|
|
175
|
+
if (present.authFiles !== undefined) {
|
|
176
|
+
model.authFiles = sandboxPromptAuthFiles(present.authFiles);
|
|
177
|
+
}
|
|
178
|
+
return model;
|
|
179
|
+
}
|
|
180
|
+
function sandboxPromptBackendServer(value) {
|
|
181
|
+
const present = plainRecord(value, "Tangle prompt backend server options");
|
|
182
|
+
assertDeclaredFields(present, new Set(["port", "hostname"]), "Tangle prompt backend server options");
|
|
183
|
+
const server = {};
|
|
184
|
+
if (present.port !== undefined) {
|
|
185
|
+
if (!Number.isSafeInteger(present.port) ||
|
|
186
|
+
present.port < 1 ||
|
|
187
|
+
present.port > 65_535) {
|
|
188
|
+
throw new Error("Tangle prompt backend server port must be a TCP port number");
|
|
189
|
+
}
|
|
190
|
+
server.port = present.port;
|
|
191
|
+
}
|
|
192
|
+
if (present.hostname !== undefined) {
|
|
193
|
+
if (typeof present.hostname !== "string" || present.hostname === "") {
|
|
194
|
+
throw new Error("Tangle prompt backend server hostname must be a non-empty string");
|
|
195
|
+
}
|
|
196
|
+
server.hostname = boundedString(present.hostname, "Tangle prompt backend server hostname");
|
|
197
|
+
}
|
|
198
|
+
return server;
|
|
199
|
+
}
|
|
200
|
+
function sandboxPromptInteractions(value) {
|
|
201
|
+
const present = plainRecord(value, "Tangle prompt backend interactions");
|
|
202
|
+
assertDeclaredFields(present, new Set(["permission", "question", "plan"]), "Tangle prompt backend interactions");
|
|
203
|
+
const interactions = {};
|
|
204
|
+
for (const kind of ["permission", "question", "plan"]) {
|
|
205
|
+
if (present[kind] === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
if (typeof present[kind] !== "boolean") {
|
|
208
|
+
throw new Error(`Tangle prompt backend interaction ${kind} must be a boolean`);
|
|
209
|
+
}
|
|
210
|
+
interactions[kind] = present[kind];
|
|
211
|
+
}
|
|
212
|
+
return interactions;
|
|
213
|
+
}
|
|
214
|
+
/** Sandbox caps trace attributes, and its own documentation forbids secrets there. */
|
|
215
|
+
const MAX_TRACE_ATTRIBUTES = 32;
|
|
216
|
+
const MAX_TRACE_ATTRIBUTE_KEY_LENGTH = 128;
|
|
217
|
+
const MAX_TRACE_ATTRIBUTE_VALUE_LENGTH = 1_024;
|
|
218
|
+
function sandboxPromptBackendMetadata(value) {
|
|
219
|
+
const present = plainRecord(value, "Tangle prompt backend metadata");
|
|
220
|
+
assertDeclaredFields(present, new Set(["containerType", "traceAttributes"]), "Tangle prompt backend metadata");
|
|
221
|
+
const metadata = {};
|
|
222
|
+
if (present.containerType !== undefined) {
|
|
223
|
+
if (typeof present.containerType !== "string" || present.containerType === "") {
|
|
224
|
+
throw new Error("Tangle prompt backend metadata containerType must be a non-empty string");
|
|
225
|
+
}
|
|
226
|
+
metadata.containerType = boundedString(present.containerType, "Tangle prompt backend metadata containerType");
|
|
227
|
+
}
|
|
228
|
+
if (present.traceAttributes !== undefined) {
|
|
229
|
+
const attributes = plainRecord(present.traceAttributes, "Tangle prompt backend metadata traceAttributes");
|
|
230
|
+
const entries = Object.entries(attributes);
|
|
231
|
+
if (entries.length > MAX_TRACE_ATTRIBUTES) {
|
|
232
|
+
throw new Error(`Tangle prompt backend metadata traceAttributes exceeds ${MAX_TRACE_ATTRIBUTES} entries`);
|
|
233
|
+
}
|
|
234
|
+
for (const [key, attribute] of entries) {
|
|
235
|
+
if (key.length > MAX_TRACE_ATTRIBUTE_KEY_LENGTH) {
|
|
236
|
+
throw new Error("Tangle prompt backend metadata traceAttributes key exceeds its bound");
|
|
237
|
+
}
|
|
238
|
+
if (typeof attribute !== "string" ||
|
|
239
|
+
attribute.length > MAX_TRACE_ATTRIBUTE_VALUE_LENGTH) {
|
|
240
|
+
throw new Error("Tangle prompt backend metadata traceAttributes value must be a bounded string");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
metadata.traceAttributes = { ...attributes };
|
|
244
|
+
}
|
|
245
|
+
return metadata;
|
|
246
|
+
}
|
|
247
|
+
function sandboxPromptAuthFiles(value) {
|
|
248
|
+
if (!Array.isArray(value)) {
|
|
249
|
+
throw new Error("Tangle prompt backend authFiles must be an array");
|
|
250
|
+
}
|
|
251
|
+
return value.map((entry) => {
|
|
252
|
+
const present = plainRecord(entry, "Tangle prompt backend auth file");
|
|
253
|
+
assertDeclaredFields(present, new Set(SANDBOX_AUTH_FILE_FIELD_LIST), "Tangle prompt backend auth file fields");
|
|
254
|
+
if (typeof present.path !== "string" || present.path.length === 0) {
|
|
255
|
+
throw new Error("Tangle prompt backend auth file requires a path");
|
|
256
|
+
}
|
|
257
|
+
if (typeof present.content !== "string") {
|
|
258
|
+
throw new Error("Tangle prompt backend auth file requires string content");
|
|
259
|
+
}
|
|
260
|
+
if (present.mode !== undefined && !Number.isSafeInteger(present.mode)) {
|
|
261
|
+
throw new Error("Tangle prompt backend auth file mode must be an integer");
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
path: boundedString(present.path, "Tangle prompt backend auth file path"),
|
|
265
|
+
content: present.content,
|
|
266
|
+
...(present.mode === undefined ? {} : { mode: present.mode }),
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function assertDeclaredFields(record, declared, label) {
|
|
271
|
+
const unsupported = Object.keys(record).filter((key) => !declared.has(key));
|
|
272
|
+
if (unsupported.length > 0) {
|
|
273
|
+
throw new Error(`${label} are not supported: ${unsupported.sort().join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function plainRecord(value, label) {
|
|
277
|
+
if (!value ||
|
|
278
|
+
typeof value !== "object" ||
|
|
279
|
+
Array.isArray(value) ||
|
|
280
|
+
(Object.getPrototypeOf(value) !== Object.prototype &&
|
|
281
|
+
Object.getPrototypeOf(value) !== null)) {
|
|
282
|
+
throw new Error(`${label} must be a JSON object`);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The part of the backend options that decides which work a turn is.
|
|
288
|
+
*
|
|
289
|
+
* Bearer material is deliberately absent. A rotated seat token is the same
|
|
290
|
+
* seat running the same work, so digesting the token bytes would make an
|
|
291
|
+
* ordinary refresh conflict with the run it is continuing. The auth files are
|
|
292
|
+
* reduced to the paths and modes they install, which states that the turn
|
|
293
|
+
* carries a credential bundle and which slots it fills, without binding the
|
|
294
|
+
* identity to the secret inside.
|
|
295
|
+
*/
|
|
296
|
+
export function backendRequestIdentity(backend) {
|
|
297
|
+
if (backend === undefined)
|
|
298
|
+
return undefined;
|
|
299
|
+
const model = backend.model;
|
|
300
|
+
if (model === undefined)
|
|
301
|
+
return { ...backend };
|
|
302
|
+
const { apiKey: _apiKey, authFiles, ...rest } = model;
|
|
303
|
+
return {
|
|
304
|
+
...backend,
|
|
305
|
+
model: {
|
|
306
|
+
...rest,
|
|
307
|
+
...(authFiles === undefined
|
|
308
|
+
? {}
|
|
309
|
+
: {
|
|
310
|
+
authFiles: authFiles.map((file) => ({
|
|
311
|
+
path: file.path,
|
|
312
|
+
...(file.mode === undefined ? {} : { mode: file.mode }),
|
|
313
|
+
})),
|
|
314
|
+
}),
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Combine the turn's requested interaction posture with its backend options.
|
|
320
|
+
*
|
|
321
|
+
* agent-runtime emits both: it maps `backend.interactions` onto the canonical
|
|
322
|
+
* `AgentTurnInput.interactions` and carries the whole backend block through
|
|
323
|
+
* `providerOptions`. The two must agree, and a disagreement is refused rather
|
|
324
|
+
* than resolved by preference, because either answer would run the turn on a
|
|
325
|
+
* posture the caller did not ask for.
|
|
326
|
+
*/
|
|
327
|
+
function turnBackendOptions(input) {
|
|
328
|
+
const backend = backendFromTurnProviderOptions(input.providerOptions);
|
|
329
|
+
const backendModel = backend?.model?.model;
|
|
330
|
+
if (input.model !== undefined &&
|
|
331
|
+
backendModel !== undefined &&
|
|
332
|
+
backendModel !== input.model) {
|
|
333
|
+
throw new Error("Tangle turn model conflicts with its backend model");
|
|
334
|
+
}
|
|
335
|
+
if (input.interactions === undefined)
|
|
336
|
+
return backend;
|
|
337
|
+
if (backend === undefined)
|
|
338
|
+
return { interactions: input.interactions };
|
|
339
|
+
if (backend.interactions !== undefined &&
|
|
340
|
+
!sameRequestedInteractions(backend.interactions, input.interactions)) {
|
|
341
|
+
throw new Error("Tangle turn interactions conflict with its backend interactions");
|
|
342
|
+
}
|
|
343
|
+
return { ...backend, interactions: input.interactions };
|
|
344
|
+
}
|
|
345
|
+
function sameRequestedInteractions(left, right) {
|
|
346
|
+
return ["permission", "question", "plan"].every((kind) => (left[kind] ?? false) === (right[kind] ?? false));
|
|
347
|
+
}
|
|
64
348
|
const SANDBOX_OPTIONAL_RESULT_FIELDS = new Set([
|
|
65
349
|
"executionId",
|
|
66
350
|
"response",
|
package/dist/tangle-provider.js
CHANGED
|
@@ -5,16 +5,22 @@ import { sandboxInstanceAsEnvironment } from "./tangle-environment.js";
|
|
|
5
5
|
import { confidentialVerifierOption, createTangleWorkspaceBranching, } from "./tangle-workspace-branching.js";
|
|
6
6
|
import { assertCreateInputShape, assertMappedCreateOptions, assertMappedSecretNames, assertNoInlineSecretValues, sandboxOptionsFromCreateInput } from "./tangle-create-options.js";
|
|
7
7
|
import { statusFromUnknown } from "./tangle-environment-values.js";
|
|
8
|
+
import { awaitSandboxRunning, DEFAULT_TANGLE_READY_TIMEOUT_MS, } from "./tangle-readiness.js";
|
|
8
9
|
import { requestedResourceProfile } from "./tangle-resources.js";
|
|
9
10
|
import { assertBoundedJson, attachCleanupHandle, awaitWithSignal, boundedIdentifier, boundedString, MAX_LIST_RESULTS, SANDBOX_LIST_PAGE_SIZE, } from "./tangle-contract-safety.js";
|
|
10
11
|
export function createTangleProvider(options) {
|
|
11
12
|
const providerName = options.name ?? "tangle-sandbox";
|
|
12
13
|
boundedIdentifier(providerName, "Tangle provider name");
|
|
14
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_TANGLE_READY_TIMEOUT_MS;
|
|
15
|
+
if (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0) {
|
|
16
|
+
throw new Error("Tangle readyTimeoutMs must be a positive number of milliseconds");
|
|
17
|
+
}
|
|
13
18
|
const exactProcess = options.exactProcess
|
|
14
19
|
? createTangleExactProcessProvider({
|
|
15
20
|
client: options.client,
|
|
16
21
|
options: options.exactProcess,
|
|
17
22
|
providerName,
|
|
23
|
+
readyTimeoutMs,
|
|
18
24
|
})
|
|
19
25
|
: undefined;
|
|
20
26
|
const resolveDeclaredCapabilities = async () => {
|
|
@@ -88,6 +94,14 @@ export function createTangleProvider(options) {
|
|
|
88
94
|
throw error;
|
|
89
95
|
}
|
|
90
96
|
try {
|
|
97
|
+
input.signal?.throwIfAborted();
|
|
98
|
+
// The environment this call returns must accept a turn, and composing it
|
|
99
|
+
// reads a deployment document that a starting sandbox cannot answer.
|
|
100
|
+
// Both facts wait here, once, so no caller repeats the wait.
|
|
101
|
+
await awaitSandboxRunning(box, options.client, {
|
|
102
|
+
timeoutMs: readyTimeoutMs,
|
|
103
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
104
|
+
});
|
|
91
105
|
input.signal?.throwIfAborted();
|
|
92
106
|
const requestedResources = requestedResourceProfile(input.resources);
|
|
93
107
|
const environment = await sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities, input.signal ? { signal: input.signal } : undefined, {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SandboxClientLike, SandboxInstanceLike } from "./tangle-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* How long `create()` waits for a new sandbox to reach `running`.
|
|
4
|
+
*
|
|
5
|
+
* The value is the Sandbox SDK's own wait default, so the adapter states no
|
|
6
|
+
* second deadline that disagrees with the platform's.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_TANGLE_READY_TIMEOUT_MS = 120000;
|
|
9
|
+
/**
|
|
10
|
+
* Hold `create()` until the sandbox can accept a turn.
|
|
11
|
+
*
|
|
12
|
+
* The gap this closes is narrow and specific. `client.create()` waits by
|
|
13
|
+
* itself only when the create response reports `pending` or `provisioning`.
|
|
14
|
+
* A response that already reports `running` skips that wait, and `running`
|
|
15
|
+
* alone is not usable: the SDK's own `waitFor` treats the target as reached
|
|
16
|
+
* only when `filesystemIncarnationReadiness` is `ready`, because the box's
|
|
17
|
+
* filesystem is still being built until then. So `create()` can return a box
|
|
18
|
+
* that reports `running` while the platform still holds a lifecycle operation
|
|
19
|
+
* on it, and the first turn lands on that lock.
|
|
20
|
+
*
|
|
21
|
+
* Measured against that mechanism, 2026-09-01, discovery-lab#467: one box
|
|
22
|
+
* (`sandbox-97943ce9526d`) came back from `create()`, and the first
|
|
23
|
+
* `environment.stream()` failed 78 seconds later with "A sandbox lifecycle
|
|
24
|
+
* operation is already in progress". That string is the platform's, not the
|
|
25
|
+
* SDK's, and n is 1, so the incarnation window is the mechanism this wait
|
|
26
|
+
* addresses rather than a proven cause. A lifecycle lock held by a genuinely
|
|
27
|
+
* concurrent operation is a different failure, and no client-side wait
|
|
28
|
+
* prevents it.
|
|
29
|
+
*
|
|
30
|
+
* Readiness also decides what the environment can claim. Composing an
|
|
31
|
+
* environment reads the sandbox's deployment capability document once, and a
|
|
32
|
+
* sandbox that is not yet running cannot answer that read, so an environment
|
|
33
|
+
* composed during provisioning claims nothing for the rest of its life.
|
|
34
|
+
*
|
|
35
|
+
* The platform owns the wait, and this adapter runs no status loop of its own.
|
|
36
|
+
* `waitFor` on the created instance is preferred because it refreshes that
|
|
37
|
+
* instance in place, which keeps the create receipt and gains the runtime
|
|
38
|
+
* connection the environment reads.
|
|
39
|
+
*/
|
|
40
|
+
export declare function awaitSandboxRunning(box: SandboxInstanceLike, client: SandboxClientLike, options: {
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { awaitWithSignal } from "./tangle-contract-safety.js";
|
|
2
|
+
import { statusFromUnknown } from "./tangle-environment-values.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long `create()` waits for a new sandbox to reach `running`.
|
|
5
|
+
*
|
|
6
|
+
* The value is the Sandbox SDK's own wait default, so the adapter states no
|
|
7
|
+
* second deadline that disagrees with the platform's.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_TANGLE_READY_TIMEOUT_MS = 120_000;
|
|
10
|
+
/**
|
|
11
|
+
* Hold `create()` until the sandbox can accept a turn.
|
|
12
|
+
*
|
|
13
|
+
* The gap this closes is narrow and specific. `client.create()` waits by
|
|
14
|
+
* itself only when the create response reports `pending` or `provisioning`.
|
|
15
|
+
* A response that already reports `running` skips that wait, and `running`
|
|
16
|
+
* alone is not usable: the SDK's own `waitFor` treats the target as reached
|
|
17
|
+
* only when `filesystemIncarnationReadiness` is `ready`, because the box's
|
|
18
|
+
* filesystem is still being built until then. So `create()` can return a box
|
|
19
|
+
* that reports `running` while the platform still holds a lifecycle operation
|
|
20
|
+
* on it, and the first turn lands on that lock.
|
|
21
|
+
*
|
|
22
|
+
* Measured against that mechanism, 2026-09-01, discovery-lab#467: one box
|
|
23
|
+
* (`sandbox-97943ce9526d`) came back from `create()`, and the first
|
|
24
|
+
* `environment.stream()` failed 78 seconds later with "A sandbox lifecycle
|
|
25
|
+
* operation is already in progress". That string is the platform's, not the
|
|
26
|
+
* SDK's, and n is 1, so the incarnation window is the mechanism this wait
|
|
27
|
+
* addresses rather than a proven cause. A lifecycle lock held by a genuinely
|
|
28
|
+
* concurrent operation is a different failure, and no client-side wait
|
|
29
|
+
* prevents it.
|
|
30
|
+
*
|
|
31
|
+
* Readiness also decides what the environment can claim. Composing an
|
|
32
|
+
* environment reads the sandbox's deployment capability document once, and a
|
|
33
|
+
* sandbox that is not yet running cannot answer that read, so an environment
|
|
34
|
+
* composed during provisioning claims nothing for the rest of its life.
|
|
35
|
+
*
|
|
36
|
+
* The platform owns the wait, and this adapter runs no status loop of its own.
|
|
37
|
+
* `waitFor` on the created instance is preferred because it refreshes that
|
|
38
|
+
* instance in place, which keeps the create receipt and gains the runtime
|
|
39
|
+
* connection the environment reads.
|
|
40
|
+
*/
|
|
41
|
+
export async function awaitSandboxRunning(box, client, options) {
|
|
42
|
+
const { timeoutMs, signal } = options;
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
const waitOptions = {
|
|
45
|
+
timeoutMs,
|
|
46
|
+
...(signal ? { signal } : {}),
|
|
47
|
+
};
|
|
48
|
+
const wait = waitForRunning(box, client, waitOptions);
|
|
49
|
+
if (wait === undefined) {
|
|
50
|
+
// A client that offers no wait cannot prove readiness, so the sandbox it
|
|
51
|
+
// returned has to prove it by itself or the create call fails.
|
|
52
|
+
assertRunning(box, box.status, "the linked client provides neither instance waitFor() nor client waitForRunning()");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let observed;
|
|
56
|
+
try {
|
|
57
|
+
observed = await awaitWithSignal(wait(), signal);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// An abort is the caller's own outcome and keeps its identity.
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
throw error;
|
|
63
|
+
throw new Error(`Tangle sandbox ${box.id} did not reach running: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
// A wait that resolves is the platform's answer, and the status it leaves
|
|
66
|
+
// behind is that answer in a readable form. Reading it back costs nothing
|
|
67
|
+
// and refuses a client whose wait resolves over a sandbox that is not
|
|
68
|
+
// running, which is the one way a half-started environment could still
|
|
69
|
+
// reach the caller.
|
|
70
|
+
assertRunning(box, observed, "its wait resolved without reaching running");
|
|
71
|
+
}
|
|
72
|
+
function assertRunning(box, status, reason) {
|
|
73
|
+
const observed = statusFromUnknown(status);
|
|
74
|
+
if (observed === "running")
|
|
75
|
+
return;
|
|
76
|
+
throw new Error(`Tangle create cannot return sandbox ${box.id}: it reports ${observed} and ${reason}`);
|
|
77
|
+
}
|
|
78
|
+
function waitForRunning(box, client, options) {
|
|
79
|
+
const instanceWait = box.waitFor;
|
|
80
|
+
if (instanceWait) {
|
|
81
|
+
return async () => {
|
|
82
|
+
await instanceWait.call(box, "running", options);
|
|
83
|
+
// `waitFor` refreshes this instance in place, so this instance now holds
|
|
84
|
+
// the status the platform answered with.
|
|
85
|
+
return box.status;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const clientWait = client.waitForRunning;
|
|
89
|
+
if (clientWait) {
|
|
90
|
+
return async () => {
|
|
91
|
+
const ready = await clientWait.call(client, box.id, options);
|
|
92
|
+
// The client-side wait resolves a second instance for the same id. That
|
|
93
|
+
// instance carries the platform's answer; the created instance still
|
|
94
|
+
// holds what it was returned with until it refreshes.
|
|
95
|
+
await box.refresh?.(options.signal);
|
|
96
|
+
return ready?.id === box.id ? ready.status : undefined;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
package/dist/tangle-types.d.ts
CHANGED
|
@@ -37,6 +37,15 @@ export interface SandboxClientLike {
|
|
|
37
37
|
get?(id: string, requestOptions?: {
|
|
38
38
|
signal?: AbortSignal;
|
|
39
39
|
}): Promise<SandboxInstanceLike | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Poll a sandbox to `running` by id, and report a provision failure with the
|
|
42
|
+
* platform's own reason. It resolves a second instance for the same id, so a
|
|
43
|
+
* caller that holds the created instance refreshes that instance afterwards.
|
|
44
|
+
*/
|
|
45
|
+
waitForRunning?(id: string, options?: {
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}): Promise<SandboxInstanceLike>;
|
|
40
49
|
list?(options?: {
|
|
41
50
|
scope?: string;
|
|
42
51
|
limit?: number;
|
|
@@ -455,6 +464,15 @@ export interface SandboxInstanceLike {
|
|
|
455
464
|
* by id or when the platform reported no receipt.
|
|
456
465
|
*/
|
|
457
466
|
createReceipt?(): SandboxCreateReceiptLike | null;
|
|
467
|
+
/**
|
|
468
|
+
* Hold until this sandbox reaches a lifecycle status, refreshing this
|
|
469
|
+
* instance in place. Preferred over the client-side wait because the created
|
|
470
|
+
* instance keeps its create receipt and gains its runtime connection.
|
|
471
|
+
*/
|
|
472
|
+
waitFor?(status: "running", options?: {
|
|
473
|
+
timeoutMs?: number;
|
|
474
|
+
signal?: AbortSignal;
|
|
475
|
+
}): Promise<void>;
|
|
458
476
|
refresh?(signal?: AbortSignal): Promise<void>;
|
|
459
477
|
delete?(options?: {
|
|
460
478
|
signal?: AbortSignal;
|
|
@@ -565,6 +583,12 @@ export interface TangleProviderOptions {
|
|
|
565
583
|
validateProfile?: AgentEnvironmentProvider["validateProfile"];
|
|
566
584
|
mapCreateInput?: (input: CreateAgentEnvironmentInput) => CreateSandboxOptions;
|
|
567
585
|
exactProcess?: TangleExactProcessOptions;
|
|
586
|
+
/**
|
|
587
|
+
* How long `create()` waits for a new sandbox to reach `running` before it
|
|
588
|
+
* fails. Defaults to `DEFAULT_TANGLE_READY_TIMEOUT_MS`, exported from this
|
|
589
|
+
* package, which is the Sandbox SDK's own wait default of 120 seconds.
|
|
590
|
+
*/
|
|
591
|
+
readyTimeoutMs?: number;
|
|
568
592
|
/** External provider-key and measurement verifier for confidential forks. */
|
|
569
593
|
confidentialAttestationVerifier?: TangleConfidentialAttestationVerifier;
|
|
570
594
|
}
|
|
@@ -303,47 +303,64 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
303
303
|
: "Sandbox checkpoint inventory is unavailable", true);
|
|
304
304
|
}
|
|
305
305
|
const metadata = forkMarkerMetadata(request);
|
|
306
|
-
let
|
|
306
|
+
let returnedChild;
|
|
307
|
+
let outcome;
|
|
307
308
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
// Sandbox fork copies live VM memory, not the durable workspace view.
|
|
310
|
+
// Restore the checkpoint so files written before the checkpoint survive.
|
|
311
|
+
returnedChild = await awaitWithSignal(client.create({
|
|
312
|
+
fromSnapshot: request.checkpoint.checkpointId,
|
|
313
|
+
fromSandboxId: box.id,
|
|
310
314
|
idempotencyKey: request.idempotencyKey,
|
|
311
|
-
|
|
315
|
+
metadata,
|
|
316
|
+
...(request.name === undefined ? {} : { name: request.name }),
|
|
317
|
+
}, operation?.signal === undefined
|
|
318
|
+
? undefined
|
|
319
|
+
: { signal: operation.signal }), operation?.signal);
|
|
320
|
+
const receipt = returnedChild.createReceipt?.();
|
|
321
|
+
if (!receipt ||
|
|
322
|
+
receipt.idempotencyKeyApplied !== true ||
|
|
323
|
+
(receipt.outcome !== "created" &&
|
|
324
|
+
receipt.outcome !== "idempotent_replay")) {
|
|
325
|
+
const compensation = await compensateCreatedForkChild(returnedChild, receipt?.outcome === "created" ? "created" : "replayed", operation?.signal);
|
|
326
|
+
const removed = compensation === "destroyed" || compensation === "already_absent";
|
|
327
|
+
return forkUnknown(request, removed
|
|
328
|
+
? "Sandbox snapshot restore returned no complete create receipt; the newly created child was removed"
|
|
329
|
+
: "Sandbox snapshot restore returned no complete create receipt; child cleanup was not confirmed", !removed);
|
|
330
|
+
}
|
|
331
|
+
outcome =
|
|
332
|
+
receipt.outcome === "idempotent_replay" ? "replayed" : "created";
|
|
312
333
|
}
|
|
313
334
|
catch (error) {
|
|
314
335
|
operation?.signal?.throwIfAborted();
|
|
315
336
|
const conflict = await forkConflictFromRemote(client, box, provider, request, options.confidentialAttestationVerifier, operation?.signal);
|
|
316
337
|
return (conflict ??
|
|
317
|
-
forkUnknown(request, `Sandbox
|
|
318
|
-
}
|
|
319
|
-
if (!
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
338
|
+
forkUnknown(request, `Sandbox snapshot restore outcome is unresolved: ${safeError(error)}`, true));
|
|
339
|
+
}
|
|
340
|
+
if (!returnedChild ||
|
|
341
|
+
typeof returnedChild !== "object" ||
|
|
342
|
+
safeIdentifier(returnedChild.id) === undefined ||
|
|
343
|
+
returnedChild.id === box.id) {
|
|
344
|
+
const compensation = returnedChild
|
|
345
|
+
? await compensateCreatedForkChild(returnedChild, outcome, operation?.signal)
|
|
346
|
+
: "not_attempted";
|
|
347
|
+
const removed = compensation === "destroyed" || compensation === "already_absent";
|
|
348
|
+
return forkUnknown(request, removed
|
|
349
|
+
? "Sandbox snapshot restore returned an invalid child; the newly created child was removed"
|
|
350
|
+
: "Sandbox snapshot restore returned an invalid child; child cleanup was not confirmed", !removed);
|
|
329
351
|
}
|
|
330
|
-
const returnedChild = result.children[0];
|
|
331
352
|
const child = await completeForkChild(client, returnedChild, operation?.signal);
|
|
332
353
|
if (!child) {
|
|
333
|
-
const compensation =
|
|
334
|
-
? await compensateUnmarkedForkChild(result, returnedChild, operation?.signal)
|
|
335
|
-
: "not_attempted";
|
|
354
|
+
const compensation = await compensateCreatedForkChild(returnedChild, outcome, operation?.signal);
|
|
336
355
|
const removed = compensation === "destroyed" || compensation === "already_absent";
|
|
337
356
|
return forkUnknown(request, removed
|
|
338
|
-
? "Sandbox
|
|
339
|
-
:
|
|
340
|
-
? "Sandbox fork returned a child without a complete identity; child cleanup was not confirmed"
|
|
341
|
-
: "Sandbox fork returned a child without a complete identity", !removed);
|
|
357
|
+
? "Sandbox snapshot restore returned a child without a complete identity; the newly created child was removed"
|
|
358
|
+
: "Sandbox snapshot restore returned a child without a complete identity; child cleanup was not confirmed", !removed);
|
|
342
359
|
}
|
|
343
360
|
const childMarker = forkMarkerFromMetadata(child.metadata);
|
|
344
361
|
if (childMarker) {
|
|
345
362
|
if (!markerBelongsToSource(childMarker, provider, box.id)) {
|
|
346
|
-
return forkUnknown(request, "Sandbox
|
|
363
|
+
return forkUnknown(request, "Sandbox snapshot restore returned a child marked for another source", true);
|
|
347
364
|
}
|
|
348
365
|
if (childMarker.idempotencyKey !== request.idempotencyKey ||
|
|
349
366
|
childMarker.requestDigest !== request.requestDigest) {
|
|
@@ -351,21 +368,19 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
351
368
|
}
|
|
352
369
|
}
|
|
353
370
|
if (!childMarker) {
|
|
354
|
-
const compensation = await
|
|
371
|
+
const compensation = await compensateCreatedForkChild(returnedChild, outcome, operation?.signal);
|
|
355
372
|
const removed = compensation === "destroyed" || compensation === "already_absent";
|
|
356
373
|
return forkUnknown(request, removed
|
|
357
|
-
? "Sandbox
|
|
358
|
-
:
|
|
359
|
-
? "Sandbox fork acknowledgement omitted its provider recovery marker; child cleanup was not confirmed"
|
|
360
|
-
: "Sandbox fork acknowledgement omitted its provider recovery marker", !removed);
|
|
374
|
+
? "Sandbox snapshot restore acknowledgement omitted its provider recovery marker; the newly created child was removed"
|
|
375
|
+
: "Sandbox snapshot restore acknowledgement omitted its provider recovery marker; child cleanup was not confirmed", !removed);
|
|
361
376
|
}
|
|
362
377
|
const environment = await environmentFromChild(request, child, provider, child.createdAt, options.confidentialAttestationVerifier, operation?.signal);
|
|
363
378
|
if (!environment) {
|
|
364
|
-
return forkUnknown(request, "Sandbox
|
|
379
|
+
return forkUnknown(request, "Sandbox snapshot restore returned a child without a valid identity", true);
|
|
365
380
|
}
|
|
366
381
|
const record = { request, environment, child };
|
|
367
382
|
forks.set(request.idempotencyKey, record);
|
|
368
|
-
return forkSuccess(request, environment,
|
|
383
|
+
return forkSuccess(request, environment, outcome);
|
|
369
384
|
};
|
|
370
385
|
const lookupFork = async (input, operation) => {
|
|
371
386
|
const request = WorkspaceOperationLookupRequestSchema.parse(input);
|
|
@@ -461,14 +476,13 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
461
476
|
}
|
|
462
477
|
/** Capability support requires every operation used by recovery and cleanup. */
|
|
463
478
|
export function supportsWorkspaceBranching(box, client) {
|
|
464
|
-
return (typeof client.
|
|
479
|
+
return (typeof client.create === "function" &&
|
|
480
|
+
typeof client.list === "function" &&
|
|
465
481
|
typeof client.get === "function" &&
|
|
466
482
|
typeof box.snapshot === "function" &&
|
|
467
483
|
typeof box.listSnapshots === "function" &&
|
|
468
484
|
typeof box.deleteSnapshot === "function" &&
|
|
469
|
-
typeof box.getSnapshotOperation === "function"
|
|
470
|
-
typeof box.fork === "function" &&
|
|
471
|
-
typeof box.getForkOperation === "function");
|
|
485
|
+
typeof box.getSnapshotOperation === "function");
|
|
472
486
|
}
|
|
473
487
|
/** Drop every in-process record for a resource the platform no longer holds. */
|
|
474
488
|
function forgetRecords(records, matches) {
|
|
@@ -689,16 +703,6 @@ function validSnapshotInfo(snapshot, sandboxId) {
|
|
|
689
703
|
safeIdentifier(snapshot.sandboxId) !== undefined &&
|
|
690
704
|
(sandboxId === undefined || snapshot.sandboxId === sandboxId));
|
|
691
705
|
}
|
|
692
|
-
function validForkResult(result) {
|
|
693
|
-
return (!!result &&
|
|
694
|
-
Array.isArray(result.children) &&
|
|
695
|
-
result.children.every((child) => child !== null &&
|
|
696
|
-
typeof child === "object" &&
|
|
697
|
-
safeIdentifier(child.id) !== undefined) &&
|
|
698
|
-
result.requestedCount === 1 &&
|
|
699
|
-
result.materializedCount === result.children.length &&
|
|
700
|
-
typeof result.complete === "boolean");
|
|
701
|
-
}
|
|
702
706
|
function validOperationRecord(value) {
|
|
703
707
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
704
708
|
}
|
|
@@ -750,7 +754,7 @@ function legacyCheckpointMarkerTags(request) {
|
|
|
750
754
|
...chunks.map((chunk, index) => `${base}:material:${index}:${chunks.length}:${chunk}`),
|
|
751
755
|
];
|
|
752
756
|
}
|
|
753
|
-
function forkMarkerMetadata(request) {
|
|
757
|
+
function forkMarkerMetadata(request, materialization = "snapshot") {
|
|
754
758
|
if (request.metadata && Object.hasOwn(request.metadata, FORK_METADATA_KEY)) {
|
|
755
759
|
throw new Error(`fork metadata reserves ${FORK_METADATA_KEY}`);
|
|
756
760
|
}
|
|
@@ -760,6 +764,7 @@ function forkMarkerMetadata(request) {
|
|
|
760
764
|
idempotencyKey: request.idempotencyKey,
|
|
761
765
|
requestDigest: request.requestDigest,
|
|
762
766
|
request,
|
|
767
|
+
...(materialization === "snapshot" ? { materialization } : {}),
|
|
763
768
|
};
|
|
764
769
|
assertBoundedJson(marker);
|
|
765
770
|
return {
|
|
@@ -787,11 +792,18 @@ async function checkpointOperationSucceeded(box, marker, signal) {
|
|
|
787
792
|
lookup.kind === "checkpoint" &&
|
|
788
793
|
lookup.state === "succeeded");
|
|
789
794
|
}
|
|
790
|
-
/**
|
|
795
|
+
/** Confirm a fork child through its marker or the legacy fork ledger. */
|
|
791
796
|
async function forkOperationLookup(box, marker, signal) {
|
|
797
|
+
if (marker.materialization === "snapshot") {
|
|
798
|
+
return {
|
|
799
|
+
outcome: "found",
|
|
800
|
+
kind: "fork",
|
|
801
|
+
state: "succeeded",
|
|
802
|
+
};
|
|
803
|
+
}
|
|
792
804
|
const lookup = await awaitWithSignal(box.getForkOperation?.(marker.idempotencyKey, {
|
|
793
805
|
count: 1,
|
|
794
|
-
metadata: forkMarkerMetadata(marker.request),
|
|
806
|
+
metadata: forkMarkerMetadata(marker.request, marker.materialization),
|
|
795
807
|
}), signal);
|
|
796
808
|
return lookup;
|
|
797
809
|
}
|
|
@@ -1023,9 +1035,9 @@ async function completeForkChild(client, child, signal) {
|
|
|
1023
1035
|
return undefined;
|
|
1024
1036
|
}
|
|
1025
1037
|
}
|
|
1026
|
-
/** Remove only a child this exact call confirmed it created. */
|
|
1027
|
-
async function
|
|
1028
|
-
if (
|
|
1038
|
+
/** Remove only a child this exact create call confirmed it created. */
|
|
1039
|
+
async function compensateCreatedForkChild(child, outcome, signal) {
|
|
1040
|
+
if (outcome !== "created")
|
|
1029
1041
|
return "not_attempted";
|
|
1030
1042
|
if (typeof child.delete !== "function")
|
|
1031
1043
|
return "unconfirmed";
|
|
@@ -1266,6 +1278,10 @@ function forkMarkerFromMetadata(metadata, key) {
|
|
|
1266
1278
|
typeof parsed.idempotencyKey !== "string" ||
|
|
1267
1279
|
typeof parsed.requestDigest !== "string")
|
|
1268
1280
|
return undefined;
|
|
1281
|
+
if (parsed.materialization !== undefined &&
|
|
1282
|
+
parsed.materialization !== "snapshot") {
|
|
1283
|
+
return undefined;
|
|
1284
|
+
}
|
|
1269
1285
|
if (key !== undefined && parsed.idempotencyKey !== key)
|
|
1270
1286
|
return undefined;
|
|
1271
1287
|
const request = WorkspaceForkRequestSchema.safeParse(parsed.request);
|
|
@@ -1279,6 +1295,9 @@ function forkMarkerFromMetadata(metadata, key) {
|
|
|
1279
1295
|
idempotencyKey: parsed.idempotencyKey,
|
|
1280
1296
|
requestDigest: parsed.requestDigest,
|
|
1281
1297
|
request: request.data,
|
|
1298
|
+
...(parsed.materialization === "snapshot"
|
|
1299
|
+
? { materialization: "snapshot" }
|
|
1300
|
+
: {}),
|
|
1282
1301
|
};
|
|
1283
1302
|
}
|
|
1284
1303
|
/**
|
package/package.json
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-provider-tangle",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"main": "
|
|
8
|
-
"types": "
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
|
-
"types": "./
|
|
13
|
-
"default": "./dist/index.js"
|
|
12
|
+
"types": "./src/index.ts"
|
|
14
13
|
}
|
|
15
14
|
},
|
|
16
15
|
"repository": {
|
|
@@ -20,7 +19,16 @@
|
|
|
20
19
|
},
|
|
21
20
|
"publishConfig": {
|
|
22
21
|
"access": "public",
|
|
23
|
-
"registry": "https://registry.npmjs.org"
|
|
22
|
+
"registry": "https://registry.npmjs.org",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
24
32
|
},
|
|
25
33
|
"files": [
|
|
26
34
|
"dist/index.d.ts",
|
|
@@ -75,6 +83,8 @@
|
|
|
75
83
|
"dist/tangle-prompt.js",
|
|
76
84
|
"dist/tangle-provider.d.ts",
|
|
77
85
|
"dist/tangle-provider.js",
|
|
86
|
+
"dist/tangle-readiness.d.ts",
|
|
87
|
+
"dist/tangle-readiness.js",
|
|
78
88
|
"dist/tangle-result-values.d.ts",
|
|
79
89
|
"dist/tangle-result-values.js",
|
|
80
90
|
"dist/tangle-session-control.d.ts",
|
|
@@ -88,8 +98,15 @@
|
|
|
88
98
|
"README.md",
|
|
89
99
|
"LICENSE"
|
|
90
100
|
],
|
|
101
|
+
"scripts": {
|
|
102
|
+
"build": "tsc -p tsconfig.json",
|
|
103
|
+
"check-types": "tsc --noEmit",
|
|
104
|
+
"clean": "rm -rf dist",
|
|
105
|
+
"prepublishOnly": "pnpm run build",
|
|
106
|
+
"test": "vitest run src"
|
|
107
|
+
},
|
|
91
108
|
"dependencies": {
|
|
92
|
-
"@tangle-network/agent-interface": "^2.
|
|
109
|
+
"@tangle-network/agent-interface": "^2.2.0"
|
|
93
110
|
},
|
|
94
111
|
"peerDependencies": {
|
|
95
112
|
"@tangle-network/sandbox": ">=0.34.6 <1.0.0"
|
|
@@ -101,17 +118,11 @@
|
|
|
101
118
|
},
|
|
102
119
|
"devDependencies": {
|
|
103
120
|
"@tangle-network/agent-eval": "0.170.0",
|
|
121
|
+
"@tangle-network/agent-provider-testkit": "workspace:*",
|
|
104
122
|
"@tangle-network/agent-runtime": "0.184.0",
|
|
105
123
|
"@tangle-network/sandbox": "0.34.6",
|
|
106
|
-
"@types/node": "
|
|
124
|
+
"@types/node": "catalog:",
|
|
107
125
|
"typescript": "7.0.2",
|
|
108
|
-
"vitest": "
|
|
109
|
-
"@tangle-network/agent-provider-testkit": "0.8.6"
|
|
110
|
-
},
|
|
111
|
-
"scripts": {
|
|
112
|
-
"build": "tsc -p tsconfig.json",
|
|
113
|
-
"check-types": "tsc --noEmit",
|
|
114
|
-
"clean": "rm -rf dist",
|
|
115
|
-
"test": "vitest run src"
|
|
126
|
+
"vitest": "catalog:"
|
|
116
127
|
}
|
|
117
|
-
}
|
|
128
|
+
}
|