@prismnetwork/agent-sdk 0.4.0 → 0.5.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 +11 -9
- package/package.json +13 -4
- package/prism.d.mts +84 -0
- package/prism.mjs +93 -16
- package/toolset.d.mts +30 -0
- package/toolset.mjs +153 -38
- package/vault.mjs +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ npm install @prismnetwork/agent-sdk viem
|
|
|
16
16
|
import { PrismAgent, DEFAULT_IMAGE } from "@prismnetwork/agent-sdk";
|
|
17
17
|
|
|
18
18
|
const agent = new PrismAgent({
|
|
19
|
-
privateKey: process.env.
|
|
19
|
+
privateKey: process.env.PRISM_AGENT_KEY, // agent's wallet
|
|
20
20
|
escrow: "0x62C042265991bEa17B07229322A01850974626dA",
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -32,12 +32,14 @@ agent.endLease(lease);
|
|
|
32
32
|
## Toolset
|
|
33
33
|
|
|
34
34
|
`@prismnetwork/agent-sdk/toolset` exports `PrismToolset`, the framework-neutral
|
|
35
|
-
tool surface the
|
|
36
|
-
|
|
37
|
-
human-readable string. It holds the wallet, the open
|
|
38
|
-
spending cap in one place, reads `PRISM_AGENT_KEY
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
tool surface the framework plugins (elizaOS, Virtuals GAME) wrap: `wallet`,
|
|
36
|
+
`listGpus`, `leaseAndRun`, `run`, `endLease`, each resolving to a
|
|
37
|
+
human-readable string, including on failure. It holds the wallet, the open
|
|
38
|
+
leases and the per-lease spending cap in one place, reads `PRISM_AGENT_KEY`,
|
|
39
|
+
`PRISM_ESCROW`, `PRISM_API_BASE` and `PRISM_RPC_URL` from the environment by
|
|
40
|
+
default (`agentFromEnv` accepts a getter for hosts with their own settings
|
|
41
|
+
store), and answers the read-only questions from the public API when no wallet
|
|
42
|
+
is configured.
|
|
41
43
|
|
|
42
44
|
## Vault
|
|
43
45
|
|
|
@@ -55,7 +57,7 @@ const value = await agent.vault.get(card.item_id, { json: true });
|
|
|
55
57
|
|
|
56
58
|
`unlock()` derives the key from a signature over a fixed statement. Ethereum's
|
|
57
59
|
ECDSA is deterministic, so the same wallet reproduces the same vault on any
|
|
58
|
-
machine
|
|
60
|
+
machine; no recovery copy is held anywhere. Pass `{ passphrase }` to require a
|
|
59
61
|
second factor beyond the wallet.
|
|
60
62
|
|
|
61
63
|
Every item carries the weakest workspace trust class it may ever be released
|
|
@@ -148,4 +150,4 @@ The wallet needs two balances on Robinhood Chain (id 4663): USDG (`0x5fc5360D040
|
|
|
148
150
|
Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()` and
|
|
149
151
|
for workspace save and restore.
|
|
150
152
|
|
|
151
|
-
See
|
|
153
|
+
See [example.mjs](https://github.com/prismnetwork-tech/prism/blob/main/sdk/example.mjs) for a full run.
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Headless GPU leasing and renter-encrypted storage on Prism Network for wallet-holding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "prism.mjs",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./prism.d.mts",
|
|
10
|
+
"default": "./prism.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./toolset": {
|
|
13
|
+
"types": "./toolset.d.mts",
|
|
14
|
+
"default": "./toolset.mjs"
|
|
15
|
+
},
|
|
10
16
|
"./vault": {
|
|
11
17
|
"types": "./vault.d.ts",
|
|
12
18
|
"default": "./vault.mjs"
|
|
@@ -18,6 +24,8 @@
|
|
|
18
24
|
},
|
|
19
25
|
"files": [
|
|
20
26
|
"prism.mjs",
|
|
27
|
+
"toolset.d.mts",
|
|
28
|
+
"prism.d.mts",
|
|
21
29
|
"toolset.mjs",
|
|
22
30
|
"vault.mjs",
|
|
23
31
|
"vault.d.ts",
|
|
@@ -52,5 +60,6 @@
|
|
|
52
60
|
"license": "Apache-2.0",
|
|
53
61
|
"publishConfig": {
|
|
54
62
|
"access": "public"
|
|
55
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"types": "prism.d.mts"
|
|
56
65
|
}
|
package/prism.d.mts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export declare const robinhoodChain: unknown;
|
|
2
|
+
export declare const USDG: string;
|
|
3
|
+
export declare const DEFAULT_IMAGE: string;
|
|
4
|
+
export declare const TRUST_CLASSES: readonly ["open", "isolated", "attested", "confidential"];
|
|
5
|
+
|
|
6
|
+
export interface LeaseAccess {
|
|
7
|
+
mode?: string;
|
|
8
|
+
ssh_host?: string;
|
|
9
|
+
ssh_port?: number;
|
|
10
|
+
ssh_user?: string;
|
|
11
|
+
expires_at?: string;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LeaseHandle {
|
|
16
|
+
leaseId: number;
|
|
17
|
+
access: LeaseAccess;
|
|
18
|
+
keyPath: string;
|
|
19
|
+
keyDir: string;
|
|
20
|
+
publicKey: string;
|
|
21
|
+
fundingHash: string;
|
|
22
|
+
quote: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BatchLeaseHandle {
|
|
26
|
+
leaseId: number;
|
|
27
|
+
result: { exit_code?: number; stdout?: string; stderr?: string; truncated?: boolean };
|
|
28
|
+
fundingHash: string;
|
|
29
|
+
quote: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunResult {
|
|
33
|
+
code: number;
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
timedOut: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export declare class PrismAgent {
|
|
40
|
+
constructor(options: { privateKey: string; escrow: string; apiBase?: string; rpcUrl?: string });
|
|
41
|
+
readonly address: string;
|
|
42
|
+
readonly vault: unknown;
|
|
43
|
+
readonly workspace: unknown;
|
|
44
|
+
authenticate(): Promise<{ session: string }>;
|
|
45
|
+
offers(options?: { minTrust?: string }): Promise<Array<Record<string, unknown>>>;
|
|
46
|
+
balances(): Promise<{ address: string; usdg: string; eth: string }>;
|
|
47
|
+
transferUsdg(to: string, amountMicros: number | string | bigint): Promise<string>;
|
|
48
|
+
quote(options: {
|
|
49
|
+
image: string;
|
|
50
|
+
durationSeconds: number;
|
|
51
|
+
minVramMib?: number;
|
|
52
|
+
preferredNodeId?: string | null;
|
|
53
|
+
minTrustClass?: string;
|
|
54
|
+
command?: string | null;
|
|
55
|
+
}): Promise<Record<string, unknown>>;
|
|
56
|
+
fund(quote: Record<string, unknown>): Promise<{ hash: string; clientReference: string }>;
|
|
57
|
+
confirm(options: { quoteId: string; transactionHash: string; sshAuthorizedKey: string }): Promise<Record<string, unknown>>;
|
|
58
|
+
leases(): Promise<Array<Record<string, unknown>>>;
|
|
59
|
+
access(leaseId: number): Promise<LeaseAccess>;
|
|
60
|
+
result(leaseId: number): Promise<Record<string, unknown>>;
|
|
61
|
+
waitForResult(leaseId: number, options?: { timeoutMs?: number; intervalMs?: number }): Promise<Record<string, unknown>>;
|
|
62
|
+
waitForAccess(leaseId: number, options?: { timeoutMs?: number; intervalMs?: number }): Promise<LeaseAccess>;
|
|
63
|
+
lease(options: {
|
|
64
|
+
image: string;
|
|
65
|
+
durationSeconds: number;
|
|
66
|
+
minVramMib?: number;
|
|
67
|
+
preferredNodeId?: string | null;
|
|
68
|
+
maxDeposit?: number | string | bigint | null;
|
|
69
|
+
minTrustClass?: string;
|
|
70
|
+
command?: string | null;
|
|
71
|
+
}): Promise<LeaseHandle | BatchLeaseHandle>;
|
|
72
|
+
run(
|
|
73
|
+
lease: LeaseHandle,
|
|
74
|
+
command: string,
|
|
75
|
+
options?: { timeoutMs?: number; connectRetries?: number; connectDelayMs?: number; stdin?: string | null },
|
|
76
|
+
): Promise<RunResult>;
|
|
77
|
+
endLease(lease: LeaseHandle): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export declare class PrismError extends Error {
|
|
81
|
+
readonly status: number;
|
|
82
|
+
readonly code: string;
|
|
83
|
+
readonly body: Record<string, unknown> | null | undefined;
|
|
84
|
+
}
|
package/prism.mjs
CHANGED
|
@@ -58,7 +58,7 @@ const escrowAbi = parseAbi([
|
|
|
58
58
|
|
|
59
59
|
// Matches the limit the control plane and the node both enforce, so a command
|
|
60
60
|
// that cannot run is rejected here rather than after an escrow is funded.
|
|
61
|
-
const MAX_COMMAND_BYTES = 8 * 1024;
|
|
61
|
+
export const MAX_COMMAND_BYTES = 8 * 1024;
|
|
62
62
|
|
|
63
63
|
function assertCommand(value) {
|
|
64
64
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -107,9 +107,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
107
107
|
export class PrismAgent {
|
|
108
108
|
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
|
|
109
109
|
if (!escrow) throw new Error("escrow address is required");
|
|
110
|
+
if (typeof privateKey !== "string" || privateKey.trim() === "") {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"privateKey is required: a 32-byte hex key, with or without 0x (most surfaces read it from PRISM_AGENT_KEY)",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
110
115
|
this.apiBase = apiBase.replace(/\/$/, "");
|
|
111
116
|
this.escrow = escrow;
|
|
112
|
-
|
|
117
|
+
const trimmed = privateKey.trim();
|
|
118
|
+
try {
|
|
119
|
+
this.account = privateKeyToAccount(trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`privateKey is not a valid key: ${err?.message ?? err}. Expected 32 bytes of hex, with or without the 0x prefix.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
113
125
|
const transport = http(rpcUrl ?? robinhoodChain.rpcUrls.default.http[0]);
|
|
114
126
|
this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
|
|
115
127
|
this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
|
|
@@ -264,13 +276,43 @@ export class PrismAgent {
|
|
|
264
276
|
|
|
265
277
|
async waitForResult(leaseId, { timeoutMs = 900_000, intervalMs = 10_000 } = {}) {
|
|
266
278
|
const deadline = Date.now() + timeoutMs;
|
|
279
|
+
let polls = 0;
|
|
267
280
|
while (Date.now() < deadline) {
|
|
268
281
|
const res = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
|
|
269
282
|
if (res.status === 200) return res.body;
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
// The control plane keeps answering 404 for a batch whose node died
|
|
284
|
+
// without reporting, so check the lease state occasionally and stop
|
|
285
|
+
// waiting once it is terminal. 429 and 5xx are transient; aborting a
|
|
286
|
+
// paid wait on one would strand the deposit.
|
|
287
|
+
if (res.status === 404) {
|
|
288
|
+
polls += 1;
|
|
289
|
+
if (polls % 6 === 0) {
|
|
290
|
+
const state = await this.#terminalState(leaseId);
|
|
291
|
+
if (state) {
|
|
292
|
+
const again = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
|
|
293
|
+
if (again.status === 200) return again.body;
|
|
294
|
+
throw new PrismError(502, "batch_no_result", { lease_id: leaseId, state });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} else if (res.status !== 429 && res.status < 500) {
|
|
298
|
+
throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
|
|
299
|
+
}
|
|
300
|
+
await sleep(intervalMs);
|
|
301
|
+
}
|
|
302
|
+
throw new PrismError(408, "result_timeout", { lease_id: leaseId });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async #terminalState(leaseId) {
|
|
306
|
+
let leases;
|
|
307
|
+
try {
|
|
308
|
+
leases = await this.leases();
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
272
311
|
}
|
|
273
|
-
|
|
312
|
+
const record = Array.isArray(leases) ? leases.find((l) => l.lease_id === leaseId) : null;
|
|
313
|
+
const state = record?.state ?? "";
|
|
314
|
+
const terminal = ["closing", "settlement_pending", "finalized", "refunded", "failed"];
|
|
315
|
+
return terminal.includes(state) ? state : null;
|
|
274
316
|
}
|
|
275
317
|
|
|
276
318
|
async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
|
|
@@ -281,10 +323,12 @@ export class PrismAgent {
|
|
|
281
323
|
if (!res.body?.ssh_host && res.body?.mode !== "gateway") throw new PrismError(502, "malformed_access");
|
|
282
324
|
return res.body;
|
|
283
325
|
}
|
|
284
|
-
if (res.status !== 404
|
|
326
|
+
if (res.status !== 404 && res.status !== 429 && res.status < 500) {
|
|
327
|
+
throw new PrismError(res.status, res.body?.error ?? "access_error", res.body);
|
|
328
|
+
}
|
|
285
329
|
await sleep(intervalMs);
|
|
286
330
|
}
|
|
287
|
-
throw new PrismError(408, "access_timeout");
|
|
331
|
+
throw new PrismError(408, "access_timeout", { lease_id: leaseId });
|
|
288
332
|
}
|
|
289
333
|
|
|
290
334
|
// quote -> ssh keygen -> fund on-chain -> confirm -> wait for access.
|
|
@@ -298,6 +342,18 @@ export class PrismAgent {
|
|
|
298
342
|
command = null,
|
|
299
343
|
} = {}) {
|
|
300
344
|
if (!this.session) await this.authenticate();
|
|
345
|
+
// A wallet with no balance at all cannot fund anything, and a doomed quote
|
|
346
|
+
// still holds capacity against other renters until it expires. Refuse
|
|
347
|
+
// before quoting.
|
|
348
|
+
const balances = await this.balances();
|
|
349
|
+
if (balances.usdg === "0" || balances.eth === "0") {
|
|
350
|
+
throw new PrismError(402, "wallet_unfunded", {
|
|
351
|
+
address: this.address,
|
|
352
|
+
usdg: balances.usdg,
|
|
353
|
+
eth_wei: balances.eth,
|
|
354
|
+
hint: "the wallet needs USDG for the deposit and native ETH for gas on Robinhood Chain (id 4663) before it can lease",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
301
357
|
const quote = await this.quote({
|
|
302
358
|
image,
|
|
303
359
|
durationSeconds,
|
|
@@ -310,25 +366,30 @@ export class PrismAgent {
|
|
|
310
366
|
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
311
367
|
}
|
|
312
368
|
const key = this.#generateSshKey();
|
|
369
|
+
let funded = null;
|
|
370
|
+
let leaseId = null;
|
|
313
371
|
try {
|
|
314
|
-
|
|
372
|
+
funded = await this.fund(quote);
|
|
315
373
|
const record = await this.confirm({
|
|
316
374
|
quoteId: quote.quote_id,
|
|
317
375
|
transactionHash: funded.hash,
|
|
318
376
|
sshAuthorizedKey: key.publicKey,
|
|
319
377
|
});
|
|
320
|
-
if (!Number.isInteger(record?.lease_id))
|
|
378
|
+
if (!Number.isInteger(record?.lease_id)) {
|
|
379
|
+
throw new PrismError(502, "malformed_lease_record", { funding_hash: funded.hash });
|
|
380
|
+
}
|
|
381
|
+
leaseId = record.lease_id;
|
|
321
382
|
// A batch lease never hands out access, so waiting for it would block
|
|
322
383
|
// until the timeout and then report a failure that never happened. Wait
|
|
323
384
|
// for what the command printed instead.
|
|
324
385
|
if (command !== null) {
|
|
325
|
-
const result = await this.waitForResult(
|
|
386
|
+
const result = await this.waitForResult(leaseId, { timeoutMs: durationSeconds * 1000 + 900_000 });
|
|
326
387
|
rmSync(key.dir, { recursive: true, force: true });
|
|
327
|
-
return { leaseId
|
|
388
|
+
return { leaseId, result, fundingHash: funded.hash, quote };
|
|
328
389
|
}
|
|
329
|
-
const access = await this.waitForAccess(
|
|
390
|
+
const access = await this.waitForAccess(leaseId);
|
|
330
391
|
return {
|
|
331
|
-
leaseId
|
|
392
|
+
leaseId,
|
|
332
393
|
access,
|
|
333
394
|
keyPath: key.keyPath,
|
|
334
395
|
keyDir: key.dir,
|
|
@@ -337,8 +398,19 @@ export class PrismAgent {
|
|
|
337
398
|
quote,
|
|
338
399
|
};
|
|
339
400
|
} catch (err) {
|
|
340
|
-
|
|
341
|
-
|
|
401
|
+
// Before funding, the key opens nothing; discard it. After funding it is
|
|
402
|
+
// the only way into a machine that is being paid for, so it stays on
|
|
403
|
+
// disk and the error says where everything is.
|
|
404
|
+
if (funded === null) {
|
|
405
|
+
rmSync(key.dir, { recursive: true, force: true });
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
const detail = { funding_hash: funded.hash, lease_id: leaseId, key_path: key.keyPath };
|
|
409
|
+
if (err instanceof PrismError) {
|
|
410
|
+
err.body = { ...(err.body ?? {}), ...detail };
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
throw new PrismError(502, "lease_failed_after_funding", { ...detail, cause: err?.message ?? String(err) });
|
|
342
414
|
}
|
|
343
415
|
}
|
|
344
416
|
|
|
@@ -348,7 +420,11 @@ export class PrismAgent {
|
|
|
348
420
|
// its input, which keeps anything sensitive out of the remote process table.
|
|
349
421
|
async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
|
|
350
422
|
if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
|
|
351
|
-
throw new PrismError(400, "invalid_lease_handle"
|
|
423
|
+
throw new PrismError(400, "invalid_lease_handle", {
|
|
424
|
+
mode: lease?.access?.mode ?? null,
|
|
425
|
+
lease_id: lease?.leaseId ?? null,
|
|
426
|
+
hint: "gateway-mode access has no ssh endpoint",
|
|
427
|
+
});
|
|
352
428
|
}
|
|
353
429
|
if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
|
|
354
430
|
const target = {
|
|
@@ -475,6 +551,7 @@ export class PrismAgent {
|
|
|
475
551
|
export class PrismError extends Error {
|
|
476
552
|
constructor(status, code, body) {
|
|
477
553
|
super(`prism ${status}: ${code}`);
|
|
554
|
+
this.name = "PrismError";
|
|
478
555
|
this.status = status;
|
|
479
556
|
this.code = code;
|
|
480
557
|
this.body = body;
|
package/toolset.d.mts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PrismAgent } from "./prism.mjs";
|
|
2
|
+
|
|
3
|
+
export declare const DEFAULT_ESCROW: string;
|
|
4
|
+
export declare const PUBLIC_API: string;
|
|
5
|
+
export declare const NO_WALLET: string;
|
|
6
|
+
|
|
7
|
+
export declare function isRefusal(body: string): boolean;
|
|
8
|
+
|
|
9
|
+
export declare function agentFromEnv(
|
|
10
|
+
get?: (name: string) => string | undefined,
|
|
11
|
+
): PrismAgent | null;
|
|
12
|
+
|
|
13
|
+
export interface LeaseAndRunOptions {
|
|
14
|
+
command: string;
|
|
15
|
+
durationSeconds?: number;
|
|
16
|
+
minVramMib?: number;
|
|
17
|
+
image?: string;
|
|
18
|
+
maxUsdg?: number;
|
|
19
|
+
minTrustClass?: "open" | "isolated" | "attested" | "confidential";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export declare class PrismToolset {
|
|
23
|
+
constructor(options?: { agent?: PrismAgent | null; publicApi?: string });
|
|
24
|
+
readonly agent: PrismAgent | null;
|
|
25
|
+
wallet(): Promise<string>;
|
|
26
|
+
listGpus(minTrustClass?: string): Promise<string>;
|
|
27
|
+
leaseAndRun(options: LeaseAndRunOptions): Promise<string>;
|
|
28
|
+
run(leaseId: number, command: string): Promise<string>;
|
|
29
|
+
endLease(leaseId: number): string;
|
|
30
|
+
}
|
package/toolset.mjs
CHANGED
|
@@ -1,68 +1,154 @@
|
|
|
1
1
|
// A framework-neutral tool surface over PrismAgent. Agent frameworks disagree
|
|
2
2
|
// about how a tool is declared but agree about what one is: a named function
|
|
3
3
|
// with typed arguments that returns text. PrismToolset holds the wallet, the
|
|
4
|
-
// open leases, and the spending cap in one place so framework
|
|
5
|
-
// thin wrappers instead of diverging copies of the same logic.
|
|
4
|
+
// open leases, and the per-lease spending cap in one place so framework
|
|
5
|
+
// plugins stay thin wrappers instead of diverging copies of the same logic.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
7
|
+
// Every method resolves to a string, including on failure. These tools are
|
|
8
|
+
// driven by language models, and a model can act on "the wallet holds 0 USDG"
|
|
9
|
+
// where a stack trace ends the conversation. Without a wallet the read-only
|
|
10
|
+
// questions still answer from the public API, the same degradation the MCP
|
|
11
|
+
// server offers.
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import { DEFAULT_IMAGE, MAX_COMMAND_BYTES, PrismAgent, PrismError, TRUST_CLASSES } from "./prism.mjs";
|
|
10
14
|
|
|
11
15
|
export const DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA";
|
|
12
16
|
export const PUBLIC_API = "https://api.prismnetwork.tech";
|
|
13
17
|
|
|
18
|
+
export const NO_WALLET =
|
|
19
|
+
"No wallet is configured, so this needs PRISM_AGENT_KEY (a funded wallet on Robinhood Chain). " +
|
|
20
|
+
"Looking at capacity and prices works without one.";
|
|
21
|
+
|
|
14
22
|
const MICROS = 1_000_000;
|
|
23
|
+
const TRUST_MESSAGE = `min_trust_class must be one of ${TRUST_CLASSES.join(", ")}.`;
|
|
24
|
+
const COMMAND_MESSAGE = "command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.";
|
|
15
25
|
const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
// True for any string the toolset returns to describe a refusal or failure.
|
|
28
|
+
// Framework plugins map these to their own failed-action shape instead of
|
|
29
|
+
// keeping divergent copies of the wording.
|
|
30
|
+
export function isRefusal(body) {
|
|
31
|
+
return (
|
|
32
|
+
body === NO_WALLET ||
|
|
33
|
+
body === TRUST_MESSAGE ||
|
|
34
|
+
body === COMMAND_MESSAGE ||
|
|
35
|
+
body.startsWith("No active lease") ||
|
|
36
|
+
body.startsWith("The lease did not go through") ||
|
|
37
|
+
body.startsWith("The balance check failed") ||
|
|
38
|
+
body.startsWith("The command could not run") ||
|
|
39
|
+
body.startsWith("Prism capacity") ||
|
|
40
|
+
body.startsWith("command exceeds the") ||
|
|
41
|
+
body.startsWith("lease_id must be") ||
|
|
42
|
+
/^Lease \d+ is funded .* but the command could not run/.test(body)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `get` lets hosts with their own settings store (elizaOS runtimes, test
|
|
47
|
+
// harnesses) resolve the variables without mutating process.env.
|
|
48
|
+
export function agentFromEnv(get = (name) => process.env[name]) {
|
|
49
|
+
const privateKey = (get("PRISM_AGENT_KEY") ?? "").trim();
|
|
19
50
|
if (!privateKey) return null;
|
|
20
|
-
return new PrismAgent({
|
|
51
|
+
return new PrismAgent({
|
|
52
|
+
privateKey,
|
|
53
|
+
escrow: get("PRISM_ESCROW") || DEFAULT_ESCROW,
|
|
54
|
+
apiBase: get("PRISM_API_BASE") || undefined,
|
|
55
|
+
rpcUrl: get("PRISM_RPC_URL") || undefined,
|
|
56
|
+
});
|
|
21
57
|
}
|
|
22
58
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
59
|
+
function describe(err) {
|
|
60
|
+
if (err instanceof PrismError) {
|
|
61
|
+
const body = err.body ?? {};
|
|
62
|
+
if (err.code === "cost_exceeds_max") {
|
|
63
|
+
return `the quote needs ${usdg(body.required ?? 0)} but the cap is ${usdg(body.max ?? 0)}; raise maxUsdg or shorten the lease`;
|
|
64
|
+
}
|
|
65
|
+
if (err.code === "wallet_unfunded") {
|
|
66
|
+
return (
|
|
67
|
+
`wallet ${body.address} holds ${usdg(body.usdg ?? 0)} and ${(Number(body.eth_wei ?? 0) / 1e18).toFixed(6)} ` +
|
|
68
|
+
"ETH for gas; fund it on Robinhood Chain (id 4663) before leasing"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const detail = body.cause ?? body.hint ?? body.message;
|
|
72
|
+
return detail ? `${err.code} (${detail})` : err.code;
|
|
73
|
+
}
|
|
74
|
+
return err?.message ?? String(err);
|
|
75
|
+
}
|
|
26
76
|
|
|
27
77
|
export class PrismToolset {
|
|
28
78
|
#agent;
|
|
29
79
|
#leases = new Map();
|
|
30
80
|
#publicApi;
|
|
31
81
|
|
|
32
|
-
constructor({ agent, publicApi
|
|
82
|
+
constructor({ agent, publicApi } = {}) {
|
|
33
83
|
this.#agent = agent === undefined ? agentFromEnv() : agent;
|
|
34
|
-
this.#publicApi = publicApi;
|
|
84
|
+
this.#publicApi = (publicApi ?? process.env.PRISM_PUBLIC_API ?? PUBLIC_API).replace(/\/$/, "");
|
|
85
|
+
process.once("exit", () => {
|
|
86
|
+
for (const lease of this.#leases.values()) {
|
|
87
|
+
try {
|
|
88
|
+
rmSync(lease.keyDir, { recursive: true, force: true });
|
|
89
|
+
} catch {
|
|
90
|
+
/* best effort */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
35
94
|
}
|
|
36
95
|
|
|
37
96
|
get agent() {
|
|
38
97
|
return this.#agent;
|
|
39
98
|
}
|
|
40
99
|
|
|
100
|
+
#sweepExpired() {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
for (const [id, lease] of this.#leases) {
|
|
103
|
+
const expiry = Date.parse(lease.access?.expires_at ?? "");
|
|
104
|
+
if (Number.isFinite(expiry) && expiry < now) {
|
|
105
|
+
this.#agent.endLease(lease);
|
|
106
|
+
this.#leases.delete(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
41
111
|
async wallet() {
|
|
42
112
|
if (!this.#agent) return NO_WALLET;
|
|
43
|
-
|
|
113
|
+
let b;
|
|
114
|
+
try {
|
|
115
|
+
b = await this.#agent.balances();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return `The balance check failed: ${describe(err)}`;
|
|
118
|
+
}
|
|
44
119
|
return `address: ${b.address}\nusdg: ${usdg(b.usdg)}\neth: ${(Number(b.eth) / 1e18).toFixed(6)} for gas`;
|
|
45
120
|
}
|
|
46
121
|
|
|
47
|
-
async listGpus(
|
|
48
|
-
if (!TRUST_CLASSES.includes(
|
|
49
|
-
return `min_trust must be one of ${TRUST_CLASSES.join(", ")}`;
|
|
50
|
-
}
|
|
122
|
+
async listGpus(minTrustClass = "open") {
|
|
123
|
+
if (!TRUST_CLASSES.includes(minTrustClass)) return TRUST_MESSAGE;
|
|
51
124
|
let offers;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
125
|
+
try {
|
|
126
|
+
if (this.#agent) {
|
|
127
|
+
offers = await this.#agent.offers({ minTrust: minTrustClass });
|
|
128
|
+
} else {
|
|
129
|
+
const url = new URL("/v1/offers", this.#publicApi);
|
|
130
|
+
url.searchParams.set("min_trust", minTrustClass);
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
headers: { accept: "application/json" },
|
|
133
|
+
signal: AbortSignal.timeout(10_000),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) return `Prism capacity is unreachable right now (${res.status}).`;
|
|
136
|
+
offers = await res.json().catch(() => null);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return `Prism capacity is unreachable right now: ${describe(err)}`;
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(offers)) {
|
|
142
|
+
return "Prism capacity answered in an unexpected shape; try again shortly.";
|
|
143
|
+
}
|
|
144
|
+
if (!offers.length) {
|
|
145
|
+
return `No GPUs at trust class '${minTrustClass}' or above are online right now.`;
|
|
60
146
|
}
|
|
61
|
-
if (!offers.length) return "No GPUs are online to rent right now.";
|
|
62
147
|
return offers
|
|
63
148
|
.map((o) => {
|
|
64
149
|
const perHr = ((Number(o.rate_per_second) * 3600) / MICROS).toFixed(2);
|
|
65
|
-
|
|
150
|
+
const row = `${o.gpu?.model ?? "GPU"} · ${o.gpu?.vram_mib ?? "?"} MiB · ${perHr} USDG/hr · ${o.trust_class ?? "open"}`;
|
|
151
|
+
return o.staker_only ? `${row} · stakers only` : row;
|
|
66
152
|
})
|
|
67
153
|
.join("\n");
|
|
68
154
|
}
|
|
@@ -74,31 +160,60 @@ export class PrismToolset {
|
|
|
74
160
|
image = DEFAULT_IMAGE,
|
|
75
161
|
maxUsdg = 1,
|
|
76
162
|
minTrustClass = "open",
|
|
77
|
-
}) {
|
|
163
|
+
} = {}) {
|
|
78
164
|
if (!this.#agent) return NO_WALLET;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
165
|
+
if (typeof command !== "string" || command.trim() === "") return COMMAND_MESSAGE;
|
|
166
|
+
if (Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) {
|
|
167
|
+
return `command exceeds the ${MAX_COMMAND_BYTES / 1024} KiB limit; fetch the payload on the box instead of inlining it.`;
|
|
168
|
+
}
|
|
169
|
+
if (!TRUST_CLASSES.includes(minTrustClass)) return TRUST_MESSAGE;
|
|
170
|
+
this.#sweepExpired();
|
|
171
|
+
let lease;
|
|
172
|
+
try {
|
|
173
|
+
lease = await this.#agent.lease({
|
|
174
|
+
image,
|
|
175
|
+
durationSeconds,
|
|
176
|
+
minVramMib,
|
|
177
|
+
maxDeposit: Math.round(maxUsdg * MICROS),
|
|
178
|
+
minTrustClass,
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return `The lease did not go through: ${describe(err)}`;
|
|
182
|
+
}
|
|
86
183
|
this.#leases.set(lease.leaseId, lease);
|
|
87
|
-
|
|
184
|
+
let res;
|
|
185
|
+
try {
|
|
186
|
+
res = await this.#agent.run(lease, command);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return (
|
|
189
|
+
`Lease ${lease.leaseId} is funded (tx ${lease.fundingHash}) but the command could not run: ` +
|
|
190
|
+
`${describe(err)}. The lease stays open; try run(${lease.leaseId}, ...) or release it with endLease.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
88
193
|
const out = res.stdout || res.stderr || "";
|
|
89
194
|
return `lease ${lease.leaseId} funded onchain (tx ${lease.fundingHash}), exit ${res.code}:\n${out}`;
|
|
90
195
|
}
|
|
91
196
|
|
|
92
197
|
async run(leaseId, command) {
|
|
93
198
|
if (!this.#agent) return NO_WALLET;
|
|
199
|
+
leaseId = Number(leaseId);
|
|
200
|
+
if (!Number.isInteger(leaseId) || leaseId <= 0) return "lease_id must be a positive integer.";
|
|
94
201
|
const lease = this.#leases.get(leaseId);
|
|
95
202
|
if (!lease) return `No active lease ${leaseId} in this session.`;
|
|
96
|
-
|
|
203
|
+
if (typeof command !== "string" || command.trim() === "") return COMMAND_MESSAGE;
|
|
204
|
+
let res;
|
|
205
|
+
try {
|
|
206
|
+
res = await this.#agent.run(lease, command);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return `The command could not run on lease ${leaseId}: ${describe(err)}`;
|
|
209
|
+
}
|
|
97
210
|
return `exit ${res.code}:\n${res.stdout || res.stderr || ""}`;
|
|
98
211
|
}
|
|
99
212
|
|
|
100
213
|
endLease(leaseId) {
|
|
101
214
|
if (!this.#agent) return NO_WALLET;
|
|
215
|
+
leaseId = Number(leaseId);
|
|
216
|
+
if (!Number.isInteger(leaseId) || leaseId <= 0) return "lease_id must be a positive integer.";
|
|
102
217
|
const lease = this.#leases.get(leaseId);
|
|
103
218
|
if (!lease) return `No active lease ${leaseId} in this session.`;
|
|
104
219
|
this.#agent.endLease(lease);
|
package/vault.mjs
CHANGED
|
@@ -65,7 +65,7 @@ function fromHex(value) {
|
|
|
65
65
|
return bytes;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/// The wallet address, lowercased. Casing varies by source
|
|
68
|
+
/// The wallet address, lowercased. Casing varies by source: a checksummed
|
|
69
69
|
/// address from one wallet and a lowercase one from another must not derive
|
|
70
70
|
/// two different keys for the same vault.
|
|
71
71
|
export function vaultWallet(address) {
|