@tangle-network/agent-provider-tangle 1.0.2 → 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 +69 -2
- 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/package.json +28 -17
package/README.md
CHANGED
|
@@ -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
|
|
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
|
}
|
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
|
+
}
|