@prismnetwork/agent-sdk 0.3.1 → 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 +73 -4
- package/package.json +20 -3
- package/prism.d.mts +84 -0
- package/prism.mjs +119 -22
- package/toolset.d.mts +30 -0
- package/toolset.mjs +223 -0
- package/vault.mjs +1 -1
- package/workspace.d.ts +101 -0
- package/workspace.mjs +503 -0
package/toolset.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// A framework-neutral tool surface over PrismAgent. Agent frameworks disagree
|
|
2
|
+
// about how a tool is declared but agree about what one is: a named function
|
|
3
|
+
// with typed arguments that returns text. PrismToolset holds the wallet, the
|
|
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
|
+
//
|
|
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";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA";
|
|
16
|
+
export const PUBLIC_API = "https://api.prismnetwork.tech";
|
|
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
|
+
|
|
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'.";
|
|
25
|
+
const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
|
|
26
|
+
|
|
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();
|
|
50
|
+
if (!privateKey) return null;
|
|
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
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
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
|
+
}
|
|
76
|
+
|
|
77
|
+
export class PrismToolset {
|
|
78
|
+
#agent;
|
|
79
|
+
#leases = new Map();
|
|
80
|
+
#publicApi;
|
|
81
|
+
|
|
82
|
+
constructor({ agent, publicApi } = {}) {
|
|
83
|
+
this.#agent = agent === undefined ? agentFromEnv() : agent;
|
|
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
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get agent() {
|
|
97
|
+
return this.#agent;
|
|
98
|
+
}
|
|
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
|
+
|
|
111
|
+
async wallet() {
|
|
112
|
+
if (!this.#agent) return NO_WALLET;
|
|
113
|
+
let b;
|
|
114
|
+
try {
|
|
115
|
+
b = await this.#agent.balances();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return `The balance check failed: ${describe(err)}`;
|
|
118
|
+
}
|
|
119
|
+
return `address: ${b.address}\nusdg: ${usdg(b.usdg)}\neth: ${(Number(b.eth) / 1e18).toFixed(6)} for gas`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async listGpus(minTrustClass = "open") {
|
|
123
|
+
if (!TRUST_CLASSES.includes(minTrustClass)) return TRUST_MESSAGE;
|
|
124
|
+
let offers;
|
|
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.`;
|
|
146
|
+
}
|
|
147
|
+
return offers
|
|
148
|
+
.map((o) => {
|
|
149
|
+
const perHr = ((Number(o.rate_per_second) * 3600) / MICROS).toFixed(2);
|
|
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;
|
|
152
|
+
})
|
|
153
|
+
.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async leaseAndRun({
|
|
157
|
+
command,
|
|
158
|
+
durationSeconds = 600,
|
|
159
|
+
minVramMib = 16000,
|
|
160
|
+
image = DEFAULT_IMAGE,
|
|
161
|
+
maxUsdg = 1,
|
|
162
|
+
minTrustClass = "open",
|
|
163
|
+
} = {}) {
|
|
164
|
+
if (!this.#agent) return NO_WALLET;
|
|
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
|
+
}
|
|
183
|
+
this.#leases.set(lease.leaseId, lease);
|
|
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
|
+
}
|
|
193
|
+
const out = res.stdout || res.stderr || "";
|
|
194
|
+
return `lease ${lease.leaseId} funded onchain (tx ${lease.fundingHash}), exit ${res.code}:\n${out}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async run(leaseId, command) {
|
|
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.";
|
|
201
|
+
const lease = this.#leases.get(leaseId);
|
|
202
|
+
if (!lease) return `No active lease ${leaseId} in this session.`;
|
|
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
|
+
}
|
|
210
|
+
return `exit ${res.code}:\n${res.stdout || res.stderr || ""}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
endLease(leaseId) {
|
|
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.";
|
|
217
|
+
const lease = this.#leases.get(leaseId);
|
|
218
|
+
if (!lease) return `No active lease ${leaseId} in this session.`;
|
|
219
|
+
this.#agent.endLease(lease);
|
|
220
|
+
this.#leases.delete(leaseId);
|
|
221
|
+
return `released lease ${leaseId}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
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) {
|
package/workspace.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type TrustFloor = "open" | "isolated" | "attested" | "confidential";
|
|
2
|
+
|
|
3
|
+
export type WorkspaceSnapshot = {
|
|
4
|
+
wrapped_key: string;
|
|
5
|
+
nonce: string;
|
|
6
|
+
ciphertext_digest: string;
|
|
7
|
+
size_bytes: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Workspace = {
|
|
11
|
+
workspace_id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
version: number;
|
|
14
|
+
snapshot?: WorkspaceSnapshot;
|
|
15
|
+
min_trust_class: TrustFloor;
|
|
16
|
+
created_at: string;
|
|
17
|
+
updated_at: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/// A presigned upload, valid for fifteen minutes. `version` is the one storage
|
|
21
|
+
/// will accept, and it is authenticated into the ciphertext, so the snapshot is
|
|
22
|
+
/// sealed after this arrives rather than before.
|
|
23
|
+
export type WorkspaceUploadGrant = {
|
|
24
|
+
url: string;
|
|
25
|
+
version: number;
|
|
26
|
+
key: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type WorkspaceDownloadGrant = WorkspaceSnapshot & {
|
|
30
|
+
url: string;
|
|
31
|
+
version: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/// The handle an interactive `lease()` returns. `save` and `restore` reach the
|
|
35
|
+
/// machine over SSH, which a batch lease has no key for.
|
|
36
|
+
export type LeaseHandle = {
|
|
37
|
+
access: { ssh_host: string; ssh_port: number; ssh_user?: string };
|
|
38
|
+
keyPath: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/// What the workspace client needs from its caller: an address, a way to sign,
|
|
42
|
+
/// a way to reach the control plane, and, for `save` and `restore`, a way to
|
|
43
|
+
/// reach the leased machine. `signVaultStatement` is the generic "sign this
|
|
44
|
+
/// statement" hook the vault also uses; the statement differs, so the keys do.
|
|
45
|
+
export type WorkspaceTransport = {
|
|
46
|
+
address: string;
|
|
47
|
+
session: unknown;
|
|
48
|
+
authenticate: () => Promise<unknown>;
|
|
49
|
+
signVaultStatement: (statement: string) => Promise<string>;
|
|
50
|
+
workspaceRequest: (method: string, segments: string[], options?: { body?: unknown }) => Promise<any>;
|
|
51
|
+
run?: (
|
|
52
|
+
lease: LeaseHandle,
|
|
53
|
+
command: string,
|
|
54
|
+
options?: { timeoutMs?: number; stdin?: string | null },
|
|
55
|
+
) => Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export declare const WORKSPACE_ENVELOPE_DOMAIN: string;
|
|
59
|
+
export declare const WORKSPACE_KEY_STATEMENT: string;
|
|
60
|
+
export declare const DEFAULT_WORKSPACE_TRUST_FLOOR: TrustFloor;
|
|
61
|
+
|
|
62
|
+
export declare function workspaceAssociatedData(
|
|
63
|
+
wallet: string,
|
|
64
|
+
workspaceId: string,
|
|
65
|
+
version: number,
|
|
66
|
+
trustFloor: TrustFloor,
|
|
67
|
+
): Uint8Array;
|
|
68
|
+
|
|
69
|
+
export declare class PrismWorkspace {
|
|
70
|
+
constructor(transport: WorkspaceTransport);
|
|
71
|
+
readonly unlocked: boolean;
|
|
72
|
+
readonly wallet: string | null;
|
|
73
|
+
unlock(options?: { passphrase?: string | null }): Promise<this>;
|
|
74
|
+
lock(): void;
|
|
75
|
+
list(): Promise<Workspace[]>;
|
|
76
|
+
get(workspaceId: string | Workspace): Promise<Workspace>;
|
|
77
|
+
create(name: string, options?: { minTrustClass?: TrustFloor }): Promise<Workspace>;
|
|
78
|
+
remove(workspaceId: string | Workspace): Promise<null>;
|
|
79
|
+
save(
|
|
80
|
+
lease: LeaseHandle,
|
|
81
|
+
workspaceId: string | Workspace,
|
|
82
|
+
remotePath: string,
|
|
83
|
+
options?: { timeoutMs?: number },
|
|
84
|
+
): Promise<Workspace>;
|
|
85
|
+
restore(
|
|
86
|
+
lease: LeaseHandle,
|
|
87
|
+
workspaceId: string | Workspace,
|
|
88
|
+
remotePath: string,
|
|
89
|
+
options?: {
|
|
90
|
+
expectVersion?: number | null;
|
|
91
|
+
expectTrustClass?: TrustFloor | null;
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
},
|
|
94
|
+
): Promise<Workspace>;
|
|
95
|
+
static permits(trustFloor: TrustFloor, leaseTrustClass: TrustFloor): boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export declare class WorkspaceError extends Error {
|
|
99
|
+
readonly code: string;
|
|
100
|
+
readonly body?: unknown;
|
|
101
|
+
}
|