@tangle-network/agent-provider-tangle 0.9.0 → 0.11.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 +91 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/tangle-capabilities.d.ts +22 -1
- package/dist/tangle-capabilities.js +120 -4
- package/dist/tangle-create-options.js +3 -10
- package/dist/tangle-deployment-capabilities.d.ts +7 -0
- package/dist/tangle-deployment-capabilities.js +3 -0
- package/dist/tangle-environment-control.js +4 -0
- package/dist/tangle-environment-session.d.ts +9 -1
- package/dist/tangle-environment-session.js +36 -11
- package/dist/tangle-environment.d.ts +7 -1
- package/dist/tangle-environment.js +74 -3
- package/dist/tangle-events.d.ts +32 -2
- package/dist/tangle-events.js +138 -40
- package/dist/tangle-failure-reason.d.ts +13 -0
- package/dist/tangle-failure-reason.js +46 -0
- package/dist/tangle-interaction-response.d.ts +26 -0
- package/dist/tangle-interaction-response.js +169 -0
- package/dist/tangle-observation.d.ts +57 -0
- package/dist/tangle-observation.js +525 -0
- package/dist/tangle-prompt.js +3 -0
- package/dist/tangle-provider.js +3 -1
- package/dist/tangle-resources.d.ts +22 -0
- package/dist/tangle-resources.js +74 -0
- package/dist/tangle-terminal-frames.d.ts +44 -0
- package/dist/tangle-terminal-frames.js +137 -0
- package/dist/tangle-terminal.d.ts +12 -0
- package/dist/tangle-terminal.js +439 -0
- package/dist/tangle-types.d.ts +181 -1
- package/dist/tangle-usage-log.d.ts +22 -0
- package/dist/tangle-usage-log.js +22 -0
- package/package.json +19 -5
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { AccountUsageSchema, AgentEnvironmentObservationSchema, SafeEndpointSchema, } from "@tangle-network/agent-interface";
|
|
2
|
+
import { placementInfoFromLoopPlacement, statusFromUnknown } from "./tangle-environment-values.js";
|
|
3
|
+
import { awaitWithSignal } from "./tangle-contract-safety.js";
|
|
4
|
+
import { transportFailureReason } from "./tangle-failure-reason.js";
|
|
5
|
+
export function observationSurfaceSupport(box, client, requestedResources) {
|
|
6
|
+
const resourceUse = typeof box.resourceUsage === "function";
|
|
7
|
+
return {
|
|
8
|
+
identity: true,
|
|
9
|
+
lifecycle: true,
|
|
10
|
+
endpoint: readSandboxMember(() => safeEndpointFromConnection(box.connection)) !== undefined,
|
|
11
|
+
placement: typeof client.describePlacement === "function",
|
|
12
|
+
resources: resourceUse || requestedResources !== undefined,
|
|
13
|
+
resourceUse,
|
|
14
|
+
modelUsage: true,
|
|
15
|
+
computeBilling: readSandboxMember(() => computeBillingFromLease(box)) !== undefined,
|
|
16
|
+
accountUsage: typeof client.usage === "function" &&
|
|
17
|
+
typeof client.subscription === "function",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Account usage and the client-side placement surface are the only observation
|
|
22
|
+
* facts a client can establish before a sandbox exists. The rest rest on one
|
|
23
|
+
* environment's data, so a provider-stage document keeps them at the declared
|
|
24
|
+
* ceiling and each concrete sandbox measures them.
|
|
25
|
+
*/
|
|
26
|
+
export function clientObservationSurfaceSupport(client) {
|
|
27
|
+
return {
|
|
28
|
+
identity: true,
|
|
29
|
+
lifecycle: true,
|
|
30
|
+
endpoint: true,
|
|
31
|
+
placement: typeof client.describePlacement === "function",
|
|
32
|
+
resources: true,
|
|
33
|
+
resourceUse: true,
|
|
34
|
+
modelUsage: true,
|
|
35
|
+
computeBilling: true,
|
|
36
|
+
accountUsage: typeof client.usage === "function" &&
|
|
37
|
+
typeof client.subscription === "function",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Read an SDK accessor that can throw before its sandbox is usable. */
|
|
41
|
+
function readSandboxMember(read) {
|
|
42
|
+
try {
|
|
43
|
+
return read();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read the credential-free network location of the sandbox runtime.
|
|
51
|
+
*
|
|
52
|
+
* Only the scheme, host, and explicit port of the runtime URL are carried.
|
|
53
|
+
* Userinfo, path, and query are dropped, and the bearer beside the URL is
|
|
54
|
+
* never read, so an endpoint payload cannot transport a credential. A port the
|
|
55
|
+
* URL leaves to its scheme default is omitted rather than inferred. The result
|
|
56
|
+
* is held to the contract's own endpoint schema, so a runtime URL the contract
|
|
57
|
+
* refuses reads as no endpoint instead of failing the whole observation.
|
|
58
|
+
*/
|
|
59
|
+
export function safeEndpointFromConnection(connection) {
|
|
60
|
+
const runtimeUrl = connection?.runtimeUrl;
|
|
61
|
+
if (typeof runtimeUrl !== "string" || runtimeUrl.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = new URL(runtimeUrl);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
71
|
+
const host = parsed.hostname;
|
|
72
|
+
if (scheme.length === 0 || host.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
const port = parsed.port === "" ? undefined : Number.parseInt(parsed.port, 10);
|
|
75
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const endpoint = SafeEndpointSchema.safeParse({
|
|
79
|
+
scheme,
|
|
80
|
+
host,
|
|
81
|
+
...(port === undefined ? {} : { port }),
|
|
82
|
+
});
|
|
83
|
+
return endpoint.success ? endpoint.data : undefined;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Hold one surface to the schema the contract states for it.
|
|
87
|
+
*
|
|
88
|
+
* An observation is composed of independent surfaces, and every value on them
|
|
89
|
+
* comes from the platform. A value the contract refuses degrades that surface
|
|
90
|
+
* alone, so one unbounded field cannot destroy the lifecycle, endpoint,
|
|
91
|
+
* placement, resource, usage, and billing facts beside it.
|
|
92
|
+
*/
|
|
93
|
+
function heldToContract(schema, observation, surface) {
|
|
94
|
+
if (schema.safeParse(observation).success)
|
|
95
|
+
return observation;
|
|
96
|
+
return {
|
|
97
|
+
state: "unavailable",
|
|
98
|
+
reason: `the sandbox reported a ${surface} the observation contract refuses`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const observationShape = AgentEnvironmentObservationSchema.shape;
|
|
102
|
+
const placementShape = observationShape.placement.unwrap().shape;
|
|
103
|
+
const resourcesShape = observationShape.resources.unwrap().shape;
|
|
104
|
+
const resourceUseShape = observationShape.resourceUse.unwrap().shape;
|
|
105
|
+
const accountShape = AccountUsageSchema.shape;
|
|
106
|
+
/** Build the normalized, freshness-tagged observation of one environment. */
|
|
107
|
+
export async function observeTangleEnvironment(sources, options) {
|
|
108
|
+
const { box, client, provider, environmentId } = sources;
|
|
109
|
+
options?.signal?.throwIfAborted();
|
|
110
|
+
const refreshFailure = await refreshBeforeObservation(box, options);
|
|
111
|
+
options?.signal?.throwIfAborted();
|
|
112
|
+
const capturedAt = new Date().toISOString();
|
|
113
|
+
// The subject binds a live observation to its replay, so it carries only the
|
|
114
|
+
// identifiers both a create handle and a handle rebuilt by id can produce.
|
|
115
|
+
// The Sandbox client cannot read back the agent backend a create call named,
|
|
116
|
+
// so naming it here would deny that binding for the same sandbox.
|
|
117
|
+
const subject = { provider, environmentId };
|
|
118
|
+
const sample = await readResourceSample(box, options);
|
|
119
|
+
options?.signal?.throwIfAborted();
|
|
120
|
+
const account = await readAccountObservations(client, capturedAt, options);
|
|
121
|
+
options?.signal?.throwIfAborted();
|
|
122
|
+
const observation = {
|
|
123
|
+
subject,
|
|
124
|
+
capturedAt,
|
|
125
|
+
identity: {
|
|
126
|
+
state: "known",
|
|
127
|
+
value: subject,
|
|
128
|
+
provenance: reportedAt(capturedAt, "sandbox-instance"),
|
|
129
|
+
},
|
|
130
|
+
lifecycle: heldToContract(observationShape.lifecycle, freshOrStale(lifecycleFromSandbox(box), reportedAt(capturedAt, "sandbox-instance"), refreshFailure), "lifecycle"),
|
|
131
|
+
endpoint: endpointObservation(box, capturedAt, refreshFailure),
|
|
132
|
+
placement: {
|
|
133
|
+
verified: heldToContract(placementShape.verified, await verifiedPlacement(box, client, capturedAt, options), "placement"),
|
|
134
|
+
},
|
|
135
|
+
resources: {
|
|
136
|
+
...(sources.requestedResources === undefined
|
|
137
|
+
? {}
|
|
138
|
+
: { requested: sources.requestedResources }),
|
|
139
|
+
effective: heldToContract(resourcesShape.effective, effectiveResources(box, sample, capturedAt), "compute shape"),
|
|
140
|
+
},
|
|
141
|
+
resourceUse: {
|
|
142
|
+
current: heldToContract(resourceUseShape.current, currentResourceUse(sample), "resource sample"),
|
|
143
|
+
peak: heldToContract(resourceUseShape.peak, peakResourceUse(sample), "resource sample"),
|
|
144
|
+
},
|
|
145
|
+
modelUsage: heldToContract(observationShape.modelUsage, modelUsageObservation(sources.usageLog), "token usage"),
|
|
146
|
+
computeBilling: heldToContract(observationShape.computeBilling, computeBillingObservation(box, capturedAt), "compute cost"),
|
|
147
|
+
accountUsage: account,
|
|
148
|
+
};
|
|
149
|
+
options?.signal?.throwIfAborted();
|
|
150
|
+
return AgentEnvironmentObservationSchema.parse(observation);
|
|
151
|
+
}
|
|
152
|
+
function reportedAt(observedAt, source) {
|
|
153
|
+
return { origin: "reported", observedAt, source };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Tag a value the sandbox reported as stale when the refresh that would have
|
|
157
|
+
* renewed it failed. The value stays visible with the transport's own reason,
|
|
158
|
+
* so a caller reads why it is old instead of reading it as current.
|
|
159
|
+
*/
|
|
160
|
+
function freshOrStale(value, provenance, staleReason) {
|
|
161
|
+
return staleReason === undefined
|
|
162
|
+
? { state: "known", value, provenance }
|
|
163
|
+
: { state: "stale", value, provenance, reason: staleReason };
|
|
164
|
+
}
|
|
165
|
+
async function refreshBeforeObservation(box, options) {
|
|
166
|
+
if (typeof box.refresh !== "function") {
|
|
167
|
+
return "the Sandbox client cannot refresh this environment";
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await awaitWithSignal(box.refresh(options), options?.signal);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
options?.signal?.throwIfAborted();
|
|
175
|
+
return transportFailureReason("environment refresh", error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function lifecycleFromSandbox(box) {
|
|
179
|
+
const scheduledAt = isoTimestamp(readSandboxMember(() => box.expiresAt));
|
|
180
|
+
return {
|
|
181
|
+
status: statusFromUnknown(readSandboxMember(() => box.status)),
|
|
182
|
+
// The platform retires the sandbox at its lifetime bound, so the expiry is
|
|
183
|
+
// a scheduled cleanup. It is unconfirmed until the sandbox actually stops.
|
|
184
|
+
...(scheduledAt === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { cleanup: { policy: "scheduled", scheduledAt } }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function endpointObservation(box, capturedAt, refreshFailure) {
|
|
190
|
+
const connection = readSandboxMember(() => box.connection);
|
|
191
|
+
const endpoint = safeEndpointFromConnection(connection);
|
|
192
|
+
if (endpoint === undefined) {
|
|
193
|
+
const stated = typeof connection?.runtimeUrl === "string" && connection.runtimeUrl.length > 0;
|
|
194
|
+
return {
|
|
195
|
+
state: "unavailable",
|
|
196
|
+
reason: stated
|
|
197
|
+
? "the sandbox reported a runtime URL the observation contract refuses"
|
|
198
|
+
: "the sandbox reports no runtime URL for this environment",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return heldToContract(observationShape.endpoint, freshOrStale(endpoint, reportedAt(capturedAt, "sandbox-instance"), refreshFailure), "runtime endpoint");
|
|
202
|
+
}
|
|
203
|
+
async function verifiedPlacement(box, client, capturedAt, options) {
|
|
204
|
+
if (typeof client.describePlacement !== "function") {
|
|
205
|
+
return {
|
|
206
|
+
state: "unavailable",
|
|
207
|
+
reason: "the Sandbox client cannot describe placement",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const described = await awaitWithSignal(Promise.resolve(client.describePlacement(box)), options?.signal);
|
|
212
|
+
const placement = placementInfoFromLoopPlacement(described, box);
|
|
213
|
+
return {
|
|
214
|
+
state: "known",
|
|
215
|
+
value: {
|
|
216
|
+
kind: placement.kind,
|
|
217
|
+
...(placement.sandboxId === undefined ? {} : { sandboxId: placement.sandboxId }),
|
|
218
|
+
...(placement.fleetId === undefined ? {} : { fleetId: placement.fleetId }),
|
|
219
|
+
...(placement.machineId === undefined ? {} : { machineId: placement.machineId }),
|
|
220
|
+
...(placement.region === undefined ? {} : { region: placement.region }),
|
|
221
|
+
},
|
|
222
|
+
provenance: reportedAt(capturedAt, "sandbox-placement"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
options?.signal?.throwIfAborted();
|
|
227
|
+
return {
|
|
228
|
+
state: "unavailable",
|
|
229
|
+
reason: transportFailureReason("placement lookup", error),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function readResourceSample(box, options) {
|
|
234
|
+
if (typeof box.resourceUsage !== "function") {
|
|
235
|
+
return {
|
|
236
|
+
measured: false,
|
|
237
|
+
reason: "the Sandbox client reports no runtime resource usage",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const sample = await awaitWithSignal(box.resourceUsage(), options?.signal);
|
|
242
|
+
if (sample === null || sample === undefined) {
|
|
243
|
+
return {
|
|
244
|
+
measured: false,
|
|
245
|
+
reason: "the sandbox host collected no cgroup statistics",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const observedAt = isoTimestamp(Number.isFinite(sample.sampledAtMs) ? new Date(sample.sampledAtMs) : undefined);
|
|
249
|
+
if (observedAt === undefined) {
|
|
250
|
+
return {
|
|
251
|
+
measured: false,
|
|
252
|
+
reason: "the sandbox reported a resource sample without a valid sample time",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return { measured: true, sample, observedAt };
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
options?.signal?.throwIfAborted();
|
|
259
|
+
return {
|
|
260
|
+
measured: false,
|
|
261
|
+
reason: transportFailureReason("resource usage read", error),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function effectiveResources(box, sample, capturedAt) {
|
|
266
|
+
const memoryMb = sample.measured
|
|
267
|
+
? positiveInteger(sample.sample.memoryLimitMb)
|
|
268
|
+
: undefined;
|
|
269
|
+
const accelerator = readSandboxMember(() => box.gpuLease?.accelerator);
|
|
270
|
+
const acceleratorProfile = accelerator !== undefined &&
|
|
271
|
+
typeof accelerator.kind === "string" &&
|
|
272
|
+
accelerator.kind.length > 0 &&
|
|
273
|
+
positiveInteger(accelerator.count) !== undefined
|
|
274
|
+
? {
|
|
275
|
+
kind: accelerator.kind,
|
|
276
|
+
count: accelerator.count,
|
|
277
|
+
...(positiveInteger(accelerator.memoryMB) === undefined
|
|
278
|
+
? {}
|
|
279
|
+
: { memoryMb: accelerator.memoryMB }),
|
|
280
|
+
}
|
|
281
|
+
: undefined;
|
|
282
|
+
if (memoryMb === undefined && acceleratorProfile === undefined) {
|
|
283
|
+
// Sandbox states no effective CPU or disk anywhere, and an unlimited
|
|
284
|
+
// memory cgroup states no ceiling, so there is nothing to report rather
|
|
285
|
+
// than a ceiling of zero.
|
|
286
|
+
return {
|
|
287
|
+
state: "unavailable",
|
|
288
|
+
reason: sample.measured
|
|
289
|
+
? "the sandbox cgroup reports no memory limit and no accelerator is attached"
|
|
290
|
+
: sample.reason,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
state: "known",
|
|
295
|
+
value: {
|
|
296
|
+
...(memoryMb === undefined ? {} : { memoryMb }),
|
|
297
|
+
...(acceleratorProfile === undefined ? {} : { accelerator: acceleratorProfile }),
|
|
298
|
+
},
|
|
299
|
+
provenance: memoryMb === undefined
|
|
300
|
+
? reportedAt(capturedAt, "sandbox-gpu-lease")
|
|
301
|
+
: {
|
|
302
|
+
origin: "measured",
|
|
303
|
+
observedAt: sample.measured ? sample.observedAt : capturedAt,
|
|
304
|
+
source: "sandbox-cgroup",
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function currentResourceUse(sample) {
|
|
309
|
+
if (!sample.measured)
|
|
310
|
+
return { state: "unavailable", reason: sample.reason };
|
|
311
|
+
const memoryMb = nonNegativeNumber(sample.sample.memoryCurrentMb);
|
|
312
|
+
if (memoryMb === undefined) {
|
|
313
|
+
return {
|
|
314
|
+
state: "unavailable",
|
|
315
|
+
reason: "the sandbox reported an invalid current memory sample",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// The cgroup reports cumulative CPU microseconds, which is not a utilization
|
|
319
|
+
// figure, and reports no disk or accelerator use at all. Deriving a rate
|
|
320
|
+
// from one sample would publish an unmeasured number, so only memory is
|
|
321
|
+
// carried.
|
|
322
|
+
return {
|
|
323
|
+
state: "known",
|
|
324
|
+
value: { memoryMb },
|
|
325
|
+
provenance: {
|
|
326
|
+
origin: "measured",
|
|
327
|
+
observedAt: sample.observedAt,
|
|
328
|
+
source: "sandbox-cgroup",
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function peakResourceUse(sample) {
|
|
333
|
+
if (!sample.measured)
|
|
334
|
+
return { state: "unavailable", reason: sample.reason };
|
|
335
|
+
const memoryMb = nonNegativeNumber(sample.sample.memoryPeakMb);
|
|
336
|
+
if (memoryMb === undefined) {
|
|
337
|
+
return {
|
|
338
|
+
state: "unavailable",
|
|
339
|
+
reason: "the sandbox cgroup reports no peak memory high-water mark",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
state: "known",
|
|
344
|
+
value: { memoryMb },
|
|
345
|
+
provenance: {
|
|
346
|
+
origin: "measured",
|
|
347
|
+
observedAt: sample.observedAt,
|
|
348
|
+
source: "sandbox-cgroup",
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function modelUsageObservation(usageLog) {
|
|
353
|
+
const latest = usageLog.latest();
|
|
354
|
+
if (latest === undefined) {
|
|
355
|
+
return {
|
|
356
|
+
state: "unavailable",
|
|
357
|
+
reason: "no execution measured token usage through this environment handle",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// The provenance source names the execution the usage belongs to, because
|
|
361
|
+
// Sandbox measures usage per execution and never for the environment.
|
|
362
|
+
return {
|
|
363
|
+
state: "known",
|
|
364
|
+
value: latest.usage,
|
|
365
|
+
provenance: {
|
|
366
|
+
origin: "reported",
|
|
367
|
+
observedAt: latest.observedAt,
|
|
368
|
+
source: latest.executionId,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Compute cost Sandbox charges for this environment.
|
|
374
|
+
*
|
|
375
|
+
* Sandbox prices an attached GPU lease and nothing else: it publishes no
|
|
376
|
+
* per-sandbox container compute cost. A settled lease carries its billed
|
|
377
|
+
* amount; a running lease carries the running estimate, which is reported with
|
|
378
|
+
* an estimated origin so it is never read as a settled charge.
|
|
379
|
+
*/
|
|
380
|
+
function computeBillingFromLease(box) {
|
|
381
|
+
const lease = readSandboxMember(() => box.gpuLease);
|
|
382
|
+
if (lease === undefined)
|
|
383
|
+
return undefined;
|
|
384
|
+
const billed = nonNegativeNumber(lease.billing?.customerCostUsd);
|
|
385
|
+
if (billed !== undefined) {
|
|
386
|
+
return { billing: { amount: billed, currency: "USD" }, origin: "reported" };
|
|
387
|
+
}
|
|
388
|
+
const estimated = nonNegativeNumber(lease.estimatedCustomerCostUsd);
|
|
389
|
+
if (estimated !== undefined) {
|
|
390
|
+
return { billing: { amount: estimated, currency: "USD" }, origin: "estimated" };
|
|
391
|
+
}
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
function computeBillingObservation(box, capturedAt) {
|
|
395
|
+
const resolved = computeBillingFromLease(box);
|
|
396
|
+
if (resolved === undefined) {
|
|
397
|
+
return {
|
|
398
|
+
state: "unavailable",
|
|
399
|
+
reason: "Sandbox reports compute cost only for an attached GPU lease, and this environment has none",
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
state: "known",
|
|
404
|
+
value: resolved.billing,
|
|
405
|
+
provenance: {
|
|
406
|
+
origin: resolved.origin,
|
|
407
|
+
observedAt: capturedAt,
|
|
408
|
+
source: "sandbox-gpu-lease",
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
async function readAccountObservations(client, capturedAt, options) {
|
|
413
|
+
if (typeof client.usage !== "function" || typeof client.subscription !== "function") {
|
|
414
|
+
const reason = "the Sandbox client exposes no account usage or subscription surface";
|
|
415
|
+
return {
|
|
416
|
+
plan: { state: "unavailable", reason },
|
|
417
|
+
credits: { state: "unavailable", reason },
|
|
418
|
+
quota: { state: "unavailable", reason },
|
|
419
|
+
period: { state: "unavailable", reason },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
let subscriptionFailure;
|
|
423
|
+
let subscription;
|
|
424
|
+
try {
|
|
425
|
+
subscription = await awaitWithSignal(client.subscription(), options?.signal);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
options?.signal?.throwIfAborted();
|
|
429
|
+
subscriptionFailure = transportFailureReason("subscription read", error);
|
|
430
|
+
}
|
|
431
|
+
let usageFailure;
|
|
432
|
+
let usage;
|
|
433
|
+
try {
|
|
434
|
+
usage = await awaitWithSignal(client.usage(), options?.signal);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
options?.signal?.throwIfAborted();
|
|
438
|
+
usageFailure = transportFailureReason("account usage read", error);
|
|
439
|
+
}
|
|
440
|
+
const provenance = reportedAt(capturedAt, "sandbox-account");
|
|
441
|
+
return {
|
|
442
|
+
plan: heldToContract(accountShape.plan, planObservation(subscription?.plan, subscriptionFailure, provenance), "plan"),
|
|
443
|
+
credits: heldToContract(accountShape.credits, creditsObservation(subscription, subscriptionFailure, provenance), "credit balance"),
|
|
444
|
+
quota: heldToContract(accountShape.quota, quotaObservation(subscription, usage, subscriptionFailure, usageFailure, provenance), "sandbox quota"),
|
|
445
|
+
period: heldToContract(accountShape.period, periodObservation(usage, usageFailure, provenance), "billing period"),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function planObservation(plan, failure, provenance) {
|
|
449
|
+
if (failure !== undefined)
|
|
450
|
+
return { state: "unavailable", reason: failure };
|
|
451
|
+
if (typeof plan !== "string" || plan.length === 0) {
|
|
452
|
+
return { state: "unavailable", reason: "the account reports no plan" };
|
|
453
|
+
}
|
|
454
|
+
return { state: "known", value: plan, provenance };
|
|
455
|
+
}
|
|
456
|
+
function creditsObservation(subscription, failure, provenance) {
|
|
457
|
+
if (failure !== undefined)
|
|
458
|
+
return { state: "unavailable", reason: failure };
|
|
459
|
+
const remaining = subscription?.creditsAvailableUsd;
|
|
460
|
+
if (typeof remaining !== "number" || !Number.isFinite(remaining)) {
|
|
461
|
+
return { state: "unavailable", reason: "the account reports no credit balance" };
|
|
462
|
+
}
|
|
463
|
+
if (remaining < 0) {
|
|
464
|
+
// An overage plan can hold a negative balance, which the contract's
|
|
465
|
+
// non-negative remaining credit cannot state. Reporting it as zero would
|
|
466
|
+
// hide a debt, so the value is reported as absent with its cause.
|
|
467
|
+
return {
|
|
468
|
+
state: "unavailable",
|
|
469
|
+
reason: "the account credit balance is negative under an overage plan",
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
return { state: "known", value: { remaining, unit: "USD" }, provenance };
|
|
473
|
+
}
|
|
474
|
+
function quotaObservation(subscription, usage, subscriptionFailure, usageFailure, provenance) {
|
|
475
|
+
const failure = subscriptionFailure ?? usageFailure;
|
|
476
|
+
if (failure !== undefined)
|
|
477
|
+
return { state: "unavailable", reason: failure };
|
|
478
|
+
const limit = subscription?.maxConcurrentSandboxes;
|
|
479
|
+
const used = usage?.activeSandboxes;
|
|
480
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
|
|
481
|
+
return {
|
|
482
|
+
state: "unavailable",
|
|
483
|
+
reason: "the account states no concurrent sandbox limit",
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (typeof used !== "number" || !Number.isFinite(used) || used < 0) {
|
|
487
|
+
return {
|
|
488
|
+
state: "unavailable",
|
|
489
|
+
reason: "the account reports no active sandbox count",
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
state: "known",
|
|
494
|
+
value: { limit, used, remaining: Math.max(0, limit - used), unit: "sandboxes" },
|
|
495
|
+
provenance,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function periodObservation(usage, failure, provenance) {
|
|
499
|
+
if (failure !== undefined)
|
|
500
|
+
return { state: "unavailable", reason: failure };
|
|
501
|
+
const start = isoTimestamp(usage?.periodStart);
|
|
502
|
+
const end = isoTimestamp(usage?.periodEnd);
|
|
503
|
+
if (start === undefined || end === undefined) {
|
|
504
|
+
return { state: "unavailable", reason: "the account reports no billing period" };
|
|
505
|
+
}
|
|
506
|
+
return { state: "known", value: { start, end }, provenance };
|
|
507
|
+
}
|
|
508
|
+
function isoTimestamp(value) {
|
|
509
|
+
if (value === undefined)
|
|
510
|
+
return undefined;
|
|
511
|
+
const time = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
512
|
+
if (!Number.isFinite(time))
|
|
513
|
+
return undefined;
|
|
514
|
+
return new Date(time).toISOString();
|
|
515
|
+
}
|
|
516
|
+
function positiveInteger(value) {
|
|
517
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
518
|
+
? value
|
|
519
|
+
: undefined;
|
|
520
|
+
}
|
|
521
|
+
function nonNegativeNumber(value) {
|
|
522
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
523
|
+
? value
|
|
524
|
+
: undefined;
|
|
525
|
+
}
|
package/dist/tangle-prompt.js
CHANGED
|
@@ -48,6 +48,9 @@ export function promptOptionsFromTurnInput(input, target) {
|
|
|
48
48
|
return {
|
|
49
49
|
...(sessionId ? { sessionId } : {}),
|
|
50
50
|
...(input.model ? { model: input.model } : {}),
|
|
51
|
+
...(input.interactions === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { backend: { interactions: input.interactions } }),
|
|
51
54
|
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
52
55
|
...(input.context ? { context: input.context } : {}),
|
|
53
56
|
...(input.signal ? { signal: input.signal } : {}),
|
package/dist/tangle-provider.js
CHANGED
|
@@ -4,6 +4,7 @@ import { capabilitiesForClient, defaultTangleSandboxCapabilities, } from "./tang
|
|
|
4
4
|
import { sandboxInstanceAsEnvironment } from "./tangle-environment.js";
|
|
5
5
|
import { assertCreateInputShape, assertMappedCreateOptions, assertMappedSecretNames, assertNoInlineSecretValues, sandboxOptionsFromCreateInput } from "./tangle-create-options.js";
|
|
6
6
|
import { statusFromUnknown } from "./tangle-environment-values.js";
|
|
7
|
+
import { requestedResourceProfile } from "./tangle-resources.js";
|
|
7
8
|
import { assertBoundedJson, attachCleanupHandle, awaitWithSignal, boundedIdentifier, boundedString, MAX_LIST_RESULTS, } from "./tangle-contract-safety.js";
|
|
8
9
|
export function createTangleProvider(options) {
|
|
9
10
|
const providerName = options.name ?? "tangle-sandbox";
|
|
@@ -83,7 +84,8 @@ export function createTangleProvider(options) {
|
|
|
83
84
|
}
|
|
84
85
|
try {
|
|
85
86
|
input.signal?.throwIfAborted();
|
|
86
|
-
const
|
|
87
|
+
const requestedResources = requestedResourceProfile(input.resources);
|
|
88
|
+
const environment = await sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities, input.signal ? { signal: input.signal } : undefined, requestedResources === undefined ? undefined : { resources: requestedResources });
|
|
87
89
|
input.signal?.throwIfAborted();
|
|
88
90
|
return environment;
|
|
89
91
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CreateSandboxOptions } from "@tangle-network/sandbox";
|
|
2
|
+
import type { ResourceRequest } from "@tangle-network/agent-interface/environment-provider";
|
|
3
|
+
import type { ResourceProfile } from "@tangle-network/agent-interface";
|
|
4
|
+
/**
|
|
5
|
+
* Translate the contract's resource request into the Sandbox request shape.
|
|
6
|
+
*
|
|
7
|
+
* The two shapes name the same quantities differently — `cpu`/`memoryMb`/
|
|
8
|
+
* `diskMb` against `cpuCores`/`memoryMB`/`diskGB` — so a request passed
|
|
9
|
+
* through unchanged reaches the service as unknown fields and provisions a
|
|
10
|
+
* sandbox the caller never asked for. Disk is stated in gibibytes on the wire,
|
|
11
|
+
* and a request that is not a whole number of gibibytes is refused instead of
|
|
12
|
+
* being rounded to a size the caller did not name.
|
|
13
|
+
*/
|
|
14
|
+
export declare function sandboxResourcesFromResourceRequest(resources: ResourceRequest | undefined): CreateSandboxOptions["resources"] | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The compute shape the caller asked for, in the observation's units.
|
|
17
|
+
*
|
|
18
|
+
* This is the request, never a measurement, so it carries no freshness state:
|
|
19
|
+
* the contract holds it beside the freshness-tagged effective profile so the
|
|
20
|
+
* two can be compared.
|
|
21
|
+
*/
|
|
22
|
+
export declare function requestedResourceProfile(resources: ResourceRequest | undefined): ResourceProfile | undefined;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { boundedIdentifier } from "./tangle-contract-safety.js";
|
|
2
|
+
/** Megabytes in one gibibyte, the unit Sandbox states disk in. */
|
|
3
|
+
const MB_PER_GIB = 1_024;
|
|
4
|
+
/**
|
|
5
|
+
* Number of accelerator devices a single-class GPU request means. Sandbox
|
|
6
|
+
* documents 1 as the default device count for an accelerator request that
|
|
7
|
+
* states only its class, so a request naming one class is one device.
|
|
8
|
+
*/
|
|
9
|
+
const SINGLE_ACCELERATOR_COUNT = 1;
|
|
10
|
+
/**
|
|
11
|
+
* Translate the contract's resource request into the Sandbox request shape.
|
|
12
|
+
*
|
|
13
|
+
* The two shapes name the same quantities differently — `cpu`/`memoryMb`/
|
|
14
|
+
* `diskMb` against `cpuCores`/`memoryMB`/`diskGB` — so a request passed
|
|
15
|
+
* through unchanged reaches the service as unknown fields and provisions a
|
|
16
|
+
* sandbox the caller never asked for. Disk is stated in gibibytes on the wire,
|
|
17
|
+
* and a request that is not a whole number of gibibytes is refused instead of
|
|
18
|
+
* being rounded to a size the caller did not name.
|
|
19
|
+
*/
|
|
20
|
+
export function sandboxResourcesFromResourceRequest(resources) {
|
|
21
|
+
if (resources === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const { cpu, memoryMb, diskMb, gpu } = resources;
|
|
24
|
+
if (cpu !== undefined && (!Number.isSafeInteger(cpu) || cpu < 1)) {
|
|
25
|
+
throw new Error("Tangle resource cpu must be a positive safe integer");
|
|
26
|
+
}
|
|
27
|
+
if (memoryMb !== undefined &&
|
|
28
|
+
(!Number.isSafeInteger(memoryMb) || memoryMb < 1)) {
|
|
29
|
+
throw new Error("Tangle resource memoryMb must be a positive safe integer");
|
|
30
|
+
}
|
|
31
|
+
if (diskMb !== undefined) {
|
|
32
|
+
if (!Number.isSafeInteger(diskMb) || diskMb < 1) {
|
|
33
|
+
throw new Error("Tangle resource diskMb must be a positive safe integer");
|
|
34
|
+
}
|
|
35
|
+
if (diskMb % MB_PER_GIB !== 0) {
|
|
36
|
+
throw new Error("Tangle resource diskMb must be a whole number of gibibytes");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (gpu !== undefined)
|
|
40
|
+
boundedIdentifier(gpu, "Tangle GPU");
|
|
41
|
+
const mapped = {
|
|
42
|
+
...(cpu === undefined ? {} : { cpuCores: cpu }),
|
|
43
|
+
...(memoryMb === undefined ? {} : { memoryMB: memoryMb }),
|
|
44
|
+
...(diskMb === undefined ? {} : { diskGB: diskMb / MB_PER_GIB }),
|
|
45
|
+
...(gpu === undefined
|
|
46
|
+
? {}
|
|
47
|
+
: { accelerator: { kind: gpu, count: SINGLE_ACCELERATOR_COUNT } }),
|
|
48
|
+
};
|
|
49
|
+
return Object.keys(mapped).length === 0 ? undefined : mapped;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The compute shape the caller asked for, in the observation's units.
|
|
53
|
+
*
|
|
54
|
+
* This is the request, never a measurement, so it carries no freshness state:
|
|
55
|
+
* the contract holds it beside the freshness-tagged effective profile so the
|
|
56
|
+
* two can be compared.
|
|
57
|
+
*/
|
|
58
|
+
export function requestedResourceProfile(resources) {
|
|
59
|
+
if (resources === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
// The Sandbox mapping owns the validation, so a profile is only reported for
|
|
62
|
+
// a request the adapter would actually send.
|
|
63
|
+
sandboxResourcesFromResourceRequest(resources);
|
|
64
|
+
const { cpu, memoryMb, diskMb, gpu } = resources;
|
|
65
|
+
const profile = {
|
|
66
|
+
...(cpu === undefined ? {} : { cpu }),
|
|
67
|
+
...(memoryMb === undefined ? {} : { memoryMb }),
|
|
68
|
+
...(diskMb === undefined ? {} : { diskMb }),
|
|
69
|
+
...(gpu === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { accelerator: { kind: gpu, count: SINGLE_ACCELERATOR_COUNT } }),
|
|
72
|
+
};
|
|
73
|
+
return Object.keys(profile).length === 0 ? undefined : profile;
|
|
74
|
+
}
|