@elisym/mcp 0.24.0 → 0.25.1
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 +4 -2
- package/dist/index.js +431 -347
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { LIMITS, SESSION_ID_REGEX, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, exportKeyPairBytes, RELAYS, toDTag,
|
|
3
|
-
import { listAgents, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, resolveAgent, loadAgent, globalConfigPath, writeYaml,
|
|
2
|
+
import { LIMITS, SESSION_ID_REGEX, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, exportKeyPairBytes, RELAYS, toDTag, utf8ByteLength, JobWaitTimeoutError, attachmentsOf, decodeJobPayload, formatAssetAmount, estimateNetworkBaseline, formatSol as formatSol$1, resolveKnownAsset, verifyAgentIdentities, splAssetsForNetwork, deriveOwnerDelegationAta, getDelegation, resolveUsdcAsset, parseAssetAmount, delegationApproveFeeSubunits, buildApproveDelegate, decodeApproveDelegate, decodeDelegationFeeTransfer, buildRevokeDelegate, estimateSolFeeLamports, formatFeeBreakdown, getProtocolProgramId, isDefinitelyUnpaid, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, resolveLsmAsset, ElisymIdentity, ElisymClient, mintDelegationNonce, buildDelegationAuthProof, assetKey, MAX_PROOF_TTL_SECS, encodeJobPayload, NATIVE_SOL, assetByKey, sendConfirmToTerminal, ONCHAIN_DISCLAIMER, defaultCeilings, verifyOnchainCall, ONCHAIN_REFUSAL_HEADLINES, getProtocolConfig, KIND_JOB_REQUEST_BASE, KIND_JOB_RESULT_BASE, USDC_SOLANA_DEVNET, USDC_SOLANA_MAINNET, LSM_SOLANA_MAINNET, estimateAssetStatsRentLamports, formatNetworkBaseline, MAX_CALL_BASE64_CHARS, MAX_EXPLAIN_ENTRIES, MAX_EXPLAIN_TEXT_CHARS, ONCHAIN_UNATTRIBUTED_NOTICE, KNOWN_ASSETS } from '@elisym/sdk';
|
|
3
|
+
import { listAgents, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, resolveAgent, loadAgent, globalConfigPath, writeYaml, writeFileAtomic as writeFileAtomic$1, ensureGitignoreHasMessagesEntry, ensureGitignoreHasJobSessionsEntry } from '@elisym/sdk/agent-store';
|
|
4
4
|
import { createIrohTransport, loadGlobalConfig, writeGlobalConfig } from '@elisym/sdk/node';
|
|
5
|
-
import { getBase58Encoder, getBase58Decoder, generateKeyPairSigner, createSolanaRpc,
|
|
5
|
+
import { getBase58Encoder, getBase58Decoder, generateKeyPairSigner, createSolanaRpc, address, getSignatureFromTransaction, createSolanaRpcSubscriptions, sendAndConfirmTransactionFactory, pipe, createTransactionMessage, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, signTransactionMessageWithSigners, createKeyPairSignerFromBytes, getBase64Encoder, getTransactionDecoder, signTransaction, isAddress } from '@solana/kit';
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
import { generateSecretKey, nip19, getPublicKey, verifyEvent } from 'nostr-tools';
|
|
8
8
|
import { writeFile, realpath, readFile, rm, stat, mkdir, rename, unlink } from 'node:fs/promises';
|
|
@@ -22,81 +22,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
22
22
|
import { getTransferSolInstruction } from '@solana-program/system';
|
|
23
23
|
import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS, getCreateAssociatedTokenIdempotentInstruction, ASSOCIATED_TOKEN_PROGRAM_ADDRESS, getTransferCheckedInstruction } from '@solana-program/token';
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
if (raw === void 0 || raw === "devnet") {
|
|
27
|
-
return "devnet";
|
|
28
|
-
}
|
|
29
|
-
if (raw === "mainnet") {
|
|
30
|
-
return "mainnet";
|
|
31
|
-
}
|
|
32
|
-
throw new Error(
|
|
33
|
-
`Agent "${name}" has unsupported network "${raw}". Expected "devnet" or "mainnet".`
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
async function loadAgentConfig(name, passphrase) {
|
|
37
|
-
validateAgentName(name);
|
|
38
|
-
const loaded = await loadAgent(name, process.cwd(), passphrase);
|
|
39
|
-
const solPayment = loaded.yaml.payments.find((entry) => entry.chain === "solana");
|
|
40
|
-
const network = coerceNetwork(solPayment?.network, name);
|
|
41
|
-
return {
|
|
42
|
-
nostrSecretKey: loaded.secrets.nostr_secret_key,
|
|
43
|
-
solanaSecretKey: loaded.secrets.solana_secret_key,
|
|
44
|
-
relays: loaded.yaml.relays.length > 0 ? loaded.yaml.relays : void 0,
|
|
45
|
-
network,
|
|
46
|
-
payments: loaded.yaml.payments.length > 0 ? loaded.yaml.payments.map((payment2) => ({
|
|
47
|
-
chain: payment2.chain,
|
|
48
|
-
network: payment2.network,
|
|
49
|
-
address: payment2.address
|
|
50
|
-
})) : void 0,
|
|
51
|
-
security: {
|
|
52
|
-
withdrawals_enabled: loaded.yaml.security.withdrawals_enabled,
|
|
53
|
-
agent_switch_enabled: loaded.yaml.security.agent_switch_enabled
|
|
54
|
-
},
|
|
55
|
-
encrypted: loaded.encrypted
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
async function saveAgentConfig(name, input) {
|
|
59
|
-
validateAgentName(name);
|
|
60
|
-
const created = await createAgentDir({ target: "home", name, cwd: process.cwd() });
|
|
61
|
-
const network = input.network ?? "devnet";
|
|
62
|
-
try {
|
|
63
|
-
await writeFile(created.paths.yaml, "", { flag: "wx", mode: 420 });
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
66
|
-
throw new Error(`Agent "${name}" already exists.`);
|
|
67
|
-
}
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
await writeYamlInitial(created.dir, {
|
|
71
|
-
display_name: void 0,
|
|
72
|
-
description: input.description,
|
|
73
|
-
picture: void 0,
|
|
74
|
-
banner: void 0,
|
|
75
|
-
relays: input.relays,
|
|
76
|
-
payments: input.solanaAddress ? [{ chain: "solana", network, address: input.solanaAddress }] : [],
|
|
77
|
-
llm: void 0,
|
|
78
|
-
security: input.security ?? {}
|
|
79
|
-
});
|
|
80
|
-
await writeExampleSkillTemplate(created.dir);
|
|
81
|
-
await writeSecrets(
|
|
82
|
-
created.dir,
|
|
83
|
-
{
|
|
84
|
-
nostr_secret_key: input.nostrSecretKey,
|
|
85
|
-
solana_secret_key: input.solanaSecretKey
|
|
86
|
-
},
|
|
87
|
-
input.passphrase
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
async function updateAgentSecurity(name, patch, passphrase) {
|
|
91
|
-
const loaded = await loadAgent(name, process.cwd(), passphrase);
|
|
92
|
-
const merged = { ...loaded.yaml.security, ...patch };
|
|
93
|
-
await writeYaml(loaded.dir, { ...loaded.yaml, security: merged });
|
|
94
|
-
return merged;
|
|
95
|
-
}
|
|
96
|
-
async function listAgentNames() {
|
|
97
|
-
const agents = await listAgents(process.cwd());
|
|
98
|
-
return agents.map((agent) => agent.name);
|
|
99
|
-
}
|
|
25
|
+
var DEFAULT_NETWORK = "mainnet";
|
|
100
26
|
function rpcUrlFor(network) {
|
|
101
27
|
return network === "mainnet" ? "https://api.mainnet-beta.solana.com" : "https://api.devnet.solana.com";
|
|
102
28
|
}
|
|
@@ -309,6 +235,83 @@ function takeSpendWarnings(ctx, asset) {
|
|
|
309
235
|
ctx.sessionSpendWarnings.set(key, fired);
|
|
310
236
|
return lines;
|
|
311
237
|
}
|
|
238
|
+
|
|
239
|
+
// src/config.ts
|
|
240
|
+
function coerceNetwork(raw, name) {
|
|
241
|
+
if (raw === void 0 || raw === "devnet") {
|
|
242
|
+
return "devnet";
|
|
243
|
+
}
|
|
244
|
+
if (raw === "mainnet") {
|
|
245
|
+
return "mainnet";
|
|
246
|
+
}
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Agent "${name}" has unsupported network "${raw}". Expected "devnet" or "mainnet".`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
async function loadAgentConfig(name, passphrase) {
|
|
252
|
+
validateAgentName(name);
|
|
253
|
+
const loaded = await loadAgent(name, process.cwd(), passphrase);
|
|
254
|
+
const solPayment = loaded.yaml.payments.find((entry) => entry.chain === "solana");
|
|
255
|
+
const network = coerceNetwork(solPayment?.network, name);
|
|
256
|
+
return {
|
|
257
|
+
nostrSecretKey: loaded.secrets.nostr_secret_key,
|
|
258
|
+
solanaSecretKey: loaded.secrets.solana_secret_key,
|
|
259
|
+
relays: loaded.yaml.relays.length > 0 ? loaded.yaml.relays : void 0,
|
|
260
|
+
network,
|
|
261
|
+
payments: loaded.yaml.payments.length > 0 ? loaded.yaml.payments.map((payment2) => ({
|
|
262
|
+
chain: payment2.chain,
|
|
263
|
+
network: payment2.network,
|
|
264
|
+
address: payment2.address
|
|
265
|
+
})) : void 0,
|
|
266
|
+
security: {
|
|
267
|
+
withdrawals_enabled: loaded.yaml.security.withdrawals_enabled,
|
|
268
|
+
agent_switch_enabled: loaded.yaml.security.agent_switch_enabled
|
|
269
|
+
},
|
|
270
|
+
encrypted: loaded.encrypted
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async function saveAgentConfig(name, input) {
|
|
274
|
+
validateAgentName(name);
|
|
275
|
+
const created = await createAgentDir({ target: "home", name, cwd: process.cwd() });
|
|
276
|
+
const network = input.network ?? DEFAULT_NETWORK;
|
|
277
|
+
try {
|
|
278
|
+
await writeFile(created.paths.yaml, "", { flag: "wx", mode: 420 });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
281
|
+
throw new Error(`Agent "${name}" already exists.`);
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
await writeYamlInitial(created.dir, {
|
|
286
|
+
display_name: void 0,
|
|
287
|
+
description: input.description,
|
|
288
|
+
picture: void 0,
|
|
289
|
+
banner: void 0,
|
|
290
|
+
relays: input.relays,
|
|
291
|
+
payments: input.solanaAddress ? [{ chain: "solana", network, address: input.solanaAddress }] : [],
|
|
292
|
+
llm: void 0,
|
|
293
|
+
security: input.security ?? {}
|
|
294
|
+
});
|
|
295
|
+
await writeExampleSkillTemplate(created.dir);
|
|
296
|
+
await writeSecrets(
|
|
297
|
+
created.dir,
|
|
298
|
+
{
|
|
299
|
+
nostr_secret_key: input.nostrSecretKey,
|
|
300
|
+
solana_secret_key: input.solanaSecretKey
|
|
301
|
+
},
|
|
302
|
+
input.passphrase
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
async function updateAgentSecurity(name, patch, passphrase) {
|
|
306
|
+
const loaded = await loadAgent(name, process.cwd(), passphrase);
|
|
307
|
+
const merged = { ...loaded.yaml.security, ...patch };
|
|
308
|
+
await writeYaml(loaded.dir, { ...loaded.yaml, security: merged });
|
|
309
|
+
return merged;
|
|
310
|
+
}
|
|
311
|
+
async function listAgentNames() {
|
|
312
|
+
const agents = await listAgents(process.cwd());
|
|
313
|
+
return agents.map((agent) => agent.name);
|
|
314
|
+
}
|
|
312
315
|
async function writeFileAtomic(path, content, mode = 384) {
|
|
313
316
|
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
314
317
|
try {
|
|
@@ -1261,8 +1264,8 @@ var CreateAgentSchema = z.object({
|
|
|
1261
1264
|
// customer-mode in 0.1.x and never publishes a NIP-89 capability card,
|
|
1262
1265
|
// so an advertised capability list would be misleading. Provider-mode
|
|
1263
1266
|
// (0.2.0) will reintroduce this field.
|
|
1264
|
-
network: z.enum(["devnet", "mainnet"]).default(
|
|
1265
|
-
|
|
1267
|
+
network: z.enum(["devnet", "mainnet"]).default(DEFAULT_NETWORK).describe(
|
|
1268
|
+
`Solana network this agent is bound to. FIXED AT CREATION: an agent can never change networks - switching networks means creating (or switch_agent-ing to) another agent bound to the other network. Defaults to ${DEFAULT_NETWORK}, where payments move REAL funds; pass "devnet" for an agent to experiment with.`
|
|
1266
1269
|
),
|
|
1267
1270
|
passphrase: z.string().optional().describe("Optional passphrase; if set, secret keys are encrypted at rest."),
|
|
1268
1271
|
activate: z.boolean().default(true)
|
|
@@ -2516,6 +2519,11 @@ var SubmitAndPayJobFromFileSchema = z.object({
|
|
|
2516
2519
|
),
|
|
2517
2520
|
session_id: SessionIdSchema
|
|
2518
2521
|
});
|
|
2522
|
+
var SubmitDelegatedJobFromFileSchema = SubmitAndPayJobFromFileSchema.omit({
|
|
2523
|
+
session_id: true
|
|
2524
|
+
}).extend({
|
|
2525
|
+
max_price_lamports: SubmitDelegatedJobSchema.shape.max_price_lamports
|
|
2526
|
+
});
|
|
2519
2527
|
var SubmitDiffReviewSchema = z.object({
|
|
2520
2528
|
provider_npub: z.string(),
|
|
2521
2529
|
capability: z.string().min(1).max(64).default("review").describe('Capability tag advertised by the reviewer. Override if not "review".'),
|
|
@@ -2696,6 +2704,9 @@ ${formatNetworkBaseline(baseline)}${statsHint}`;
|
|
|
2696
2704
|
return "";
|
|
2697
2705
|
}
|
|
2698
2706
|
}
|
|
2707
|
+
function isDelegatedTool(toolName) {
|
|
2708
|
+
return toolName === "submit_delegated_job" || toolName === "submit_delegated_job_from_file";
|
|
2709
|
+
}
|
|
2699
2710
|
async function confirmPriceGate(opts) {
|
|
2700
2711
|
const {
|
|
2701
2712
|
agent,
|
|
@@ -2717,7 +2728,7 @@ async function confirmPriceGate(opts) {
|
|
|
2717
2728
|
if (price > 0 && maxPriceLamports === void 0) {
|
|
2718
2729
|
const gasLine = await gasHintForCardAsset(agent, asset);
|
|
2719
2730
|
const subject = toolName === "buy_capability" ? `Capability "${capability}" from "${providerLabel}"` : `Job for capability "${capability}" from "${providerLabel}"`;
|
|
2720
|
-
const costLine = meteredMinSubunits !== void 0 && toolName
|
|
2731
|
+
const costLine = meteredMinSubunits !== void 0 && isDelegatedTool(toolName) ? `${subject} is billed for what it actually uses: from ${formatAssetAmount(asset, meteredMinSubunits)} and never more than ${formatAssetAmount(asset, BigInt(price))}.` : `${subject} costs ${formatAssetAmount(asset, BigInt(price))}.`;
|
|
2721
2732
|
const { text } = sanitizeUntrusted(
|
|
2722
2733
|
`${costLine}${gasLine}
|
|
2723
2734
|
|
|
@@ -2994,6 +3005,51 @@ async function prepareTextInput(agent, text, options) {
|
|
|
2994
3005
|
};
|
|
2995
3006
|
}
|
|
2996
3007
|
}
|
|
3008
|
+
var FILE_PROMPT_INLINE_CAP = 55e3;
|
|
3009
|
+
async function validateFileInput(ctx, options) {
|
|
3010
|
+
const prompt = options.prompt.trim();
|
|
3011
|
+
if (utf8ByteLength(prompt) > FILE_PROMPT_INLINE_CAP) {
|
|
3012
|
+
return {
|
|
3013
|
+
error: `Prompt is too long to ride inline (max ${FILE_PROMPT_INLINE_CAP} bytes); the file occupies the only attachment slot, so shorten the prompt.`
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
let file;
|
|
3017
|
+
try {
|
|
3018
|
+
file = await prepareFileInput(options.inputPath, {
|
|
3019
|
+
allowOutsideCwd: options.allowOutsideCwd
|
|
3020
|
+
});
|
|
3021
|
+
} catch (e) {
|
|
3022
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
3023
|
+
}
|
|
3024
|
+
const agent = ctx.active();
|
|
3025
|
+
if (agent.agentDir === void 0) {
|
|
3026
|
+
return {
|
|
3027
|
+
error: `Sending a file requires a persistent agent (this is an ephemeral session). Files are always transferred P2P via iroh, never inline.`
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
return { agent, prompt, file };
|
|
3031
|
+
}
|
|
3032
|
+
async function seedFileAttachment(agent, file) {
|
|
3033
|
+
try {
|
|
3034
|
+
const seeded = await ensureIrohTransport(agent).seedPath(file.absPath);
|
|
3035
|
+
return {
|
|
3036
|
+
attachment: {
|
|
3037
|
+
name: file.name,
|
|
3038
|
+
size: seeded.size,
|
|
3039
|
+
mime: file.mime,
|
|
3040
|
+
transports: [{ kind: "iroh", ticket: seeded.ticket }]
|
|
3041
|
+
}
|
|
3042
|
+
};
|
|
3043
|
+
} catch (e) {
|
|
3044
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3045
|
+
if (/@number0\/iroh|iroh file transfer is unavailable/i.test(msg)) {
|
|
3046
|
+
return {
|
|
3047
|
+
error: `File transfer is unavailable: the optional @number0/iroh addon is not installed. Install it (e.g. \`bun add @number0/iroh\`) to send files.`
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
return { error: `Failed to seed file for transfer: ${msg}` };
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
2997
3053
|
async function executeSubmitAndPay(ctx, agent, params) {
|
|
2998
3054
|
const ping = await agent.client.ping.pingAgent(params.providerPubkey, PRE_PING_TIMEOUT_MS);
|
|
2999
3055
|
if (!ping.online) {
|
|
@@ -3222,6 +3278,234 @@ ${sessionIdLine(sessionId).trimEnd()}` : "";
|
|
|
3222
3278
|
);
|
|
3223
3279
|
}
|
|
3224
3280
|
}
|
|
3281
|
+
async function executeDelegatedJob(ctx, agent, params) {
|
|
3282
|
+
if (!agent.solanaKeypair) {
|
|
3283
|
+
return errorResult(
|
|
3284
|
+
"Solana wallet not configured for this agent - delegated payment signs the proof with the owner (wallet) key."
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
const providerPubkey = decodeNpub(params.providerNpub);
|
|
3288
|
+
const dTag = toDTag(params.capability);
|
|
3289
|
+
const { timeoutMs } = params;
|
|
3290
|
+
const ping = await agent.client.ping.pingAgent(providerPubkey, PRE_PING_TIMEOUT_MS);
|
|
3291
|
+
if (!ping.online) {
|
|
3292
|
+
return errorResult(
|
|
3293
|
+
`Provider ${params.providerNpub} is offline. Run search_agents to find currently-online providers.`
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3296
|
+
const providers = await agent.client.discovery.fetchAgents(agent.network);
|
|
3297
|
+
const provider = providers.find((candidate) => candidate.npub === params.providerNpub);
|
|
3298
|
+
if (!provider) {
|
|
3299
|
+
return errorResult(
|
|
3300
|
+
`Provider ${params.providerNpub} not found on ${agent.network}. Refresh discovery (e.g. search_agents) or verify the npub is correct.`
|
|
3301
|
+
);
|
|
3302
|
+
}
|
|
3303
|
+
const card = paymentCardForCapability(provider, dTag);
|
|
3304
|
+
const descriptor = card?.delegation;
|
|
3305
|
+
if (!descriptor) {
|
|
3306
|
+
const payUpFrontTool = params.toolName === "submit_delegated_job_from_file" ? "submit_and_pay_job_from_file" : "submit_and_pay_job";
|
|
3307
|
+
return errorResult(
|
|
3308
|
+
`Capability "${params.capability}" of ${params.providerNpub} does not advertise delegated payment. Use ${payUpFrontTool} instead.`
|
|
3309
|
+
);
|
|
3310
|
+
}
|
|
3311
|
+
const { price, asset } = advertisedPriceForCapability(provider, dTag);
|
|
3312
|
+
if (price <= 0) {
|
|
3313
|
+
return errorResult(
|
|
3314
|
+
`Capability "${params.capability}" advertises no price - delegated payment needs a priced skill. Use create_job for free capabilities.`
|
|
3315
|
+
);
|
|
3316
|
+
}
|
|
3317
|
+
const delegationAsset = resolveUsdcAsset(agent.network);
|
|
3318
|
+
const cardPayment = paymentCardForCapability(provider, dTag)?.payment;
|
|
3319
|
+
const cardMint = cardPayment?.mint;
|
|
3320
|
+
const isCanonicalUsdc = (cardPayment?.chain ?? delegationAsset.chain) === delegationAsset.chain && cardPayment?.token?.toLowerCase() === delegationAsset.token && (cardMint === void 0 || cardMint === delegationAsset.mint);
|
|
3321
|
+
if (!isCanonicalUsdc) {
|
|
3322
|
+
const declared = cardPayment?.symbol ?? cardPayment?.token ?? asset.symbol;
|
|
3323
|
+
const { text } = sanitizeUntrusted(
|
|
3324
|
+
`Delegated payment is USDC-only, but this capability is priced in ${declared}.`,
|
|
3325
|
+
"text"
|
|
3326
|
+
);
|
|
3327
|
+
return errorResult(text);
|
|
3328
|
+
}
|
|
3329
|
+
const priceSubunits = BigInt(price);
|
|
3330
|
+
const rpc = createSolanaRpc(rpcUrlFor(agent.network));
|
|
3331
|
+
const ownerAta = await deriveOwnerDelegationAta(agent.solanaKeypair.publicKey, agent.network);
|
|
3332
|
+
let delegation;
|
|
3333
|
+
try {
|
|
3334
|
+
delegation = await getDelegation(rpc, ownerAta);
|
|
3335
|
+
} catch (e) {
|
|
3336
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
3337
|
+
return errorResult(`Could not read your delegation on-chain: ${message}`);
|
|
3338
|
+
}
|
|
3339
|
+
if (delegation === null || delegation.delegate === null) {
|
|
3340
|
+
return errorResult(
|
|
3341
|
+
"No active delegation from this wallet. Approve one first (web app Delegation tab, or the approve flow), then retry."
|
|
3342
|
+
);
|
|
3343
|
+
}
|
|
3344
|
+
if (delegation.delegate !== descriptor.delegate_pubkey) {
|
|
3345
|
+
const { text } = sanitizeUntrusted(
|
|
3346
|
+
`Your delegation is granted to ${delegation.delegate}, but this capability advertises delegate ${descriptor.delegate_pubkey} (the provider may have rotated its key). Re-approve the delegation, then retry.`,
|
|
3347
|
+
"text"
|
|
3348
|
+
);
|
|
3349
|
+
return errorResult(text);
|
|
3350
|
+
}
|
|
3351
|
+
if (delegation.remainingCap < priceSubunits) {
|
|
3352
|
+
return errorResult(
|
|
3353
|
+
`Remaining delegated cap ${formatAssetAmount(delegationAsset, delegation.remainingCap)} is below the price ${formatAssetAmount(delegationAsset, priceSubunits)}. Top up the delegation (re-approve) first.`
|
|
3354
|
+
);
|
|
3355
|
+
}
|
|
3356
|
+
if (delegation.balance < priceSubunits) {
|
|
3357
|
+
return errorResult(
|
|
3358
|
+
`Delegation account balance ${formatAssetAmount(delegationAsset, delegation.balance)} is below the price ${formatAssetAmount(delegationAsset, priceSubunits)}. Fund the wallet first.`
|
|
3359
|
+
);
|
|
3360
|
+
}
|
|
3361
|
+
try {
|
|
3362
|
+
assertCanSpend(ctx, delegationAsset, priceSubunits);
|
|
3363
|
+
} catch (e) {
|
|
3364
|
+
return errorResult(e instanceof Error ? e.message : String(e));
|
|
3365
|
+
}
|
|
3366
|
+
const priceGate = await confirmPriceGate({
|
|
3367
|
+
agent,
|
|
3368
|
+
providerLabel: sanitizeField(provider.name || params.providerNpub, 64),
|
|
3369
|
+
capability: params.capability,
|
|
3370
|
+
price,
|
|
3371
|
+
asset: delegationAsset,
|
|
3372
|
+
maxPriceLamports: params.maxPriceLamports,
|
|
3373
|
+
toolName: params.toolName,
|
|
3374
|
+
// Card-published floor, so the quote reads "from X, never more than Y"
|
|
3375
|
+
// instead of a flat number the buyer will usually not be charged.
|
|
3376
|
+
...card?.metered !== void 0 ? { meteredMinSubunits: BigInt(card.metered.min_subunits) } : {}
|
|
3377
|
+
});
|
|
3378
|
+
if (priceGate) {
|
|
3379
|
+
return priceGate;
|
|
3380
|
+
}
|
|
3381
|
+
const preparedInput = await params.prepareInput();
|
|
3382
|
+
if ("error" in preparedInput) {
|
|
3383
|
+
return errorResult(preparedInput.error);
|
|
3384
|
+
}
|
|
3385
|
+
const ownerSigner = await createKeyPairSignerFromBytes(agent.solanaKeypair.secretKey);
|
|
3386
|
+
const expiryUnix = Math.floor(Date.now() / 1e3) + MAX_PROOF_TTL_SECS;
|
|
3387
|
+
const nonce = mintDelegationNonce();
|
|
3388
|
+
const proof = await buildDelegationAuthProof({
|
|
3389
|
+
ownerSigner,
|
|
3390
|
+
agentDelegate: descriptor.delegate_pubkey,
|
|
3391
|
+
nostrAuthor: agent.identity.publicKey,
|
|
3392
|
+
owner: agent.solanaKeypair.publicKey,
|
|
3393
|
+
expiryUnix,
|
|
3394
|
+
nonce
|
|
3395
|
+
});
|
|
3396
|
+
const submittedAt = Date.now();
|
|
3397
|
+
const jobId = await agent.client.marketplace.submitJobRequest(agent.identity, {
|
|
3398
|
+
input: preparedInput.input,
|
|
3399
|
+
attachment: preparedInput.attachment,
|
|
3400
|
+
capability: dTag,
|
|
3401
|
+
providerPubkey,
|
|
3402
|
+
kindOffset: params.kindOffset,
|
|
3403
|
+
acceptTransports: MCP_ACCEPT_TRANSPORTS,
|
|
3404
|
+
delegatedPayment: {
|
|
3405
|
+
owner: agent.solanaKeypair.publicKey,
|
|
3406
|
+
expiryUnix,
|
|
3407
|
+
nonce,
|
|
3408
|
+
proof
|
|
3409
|
+
}
|
|
3410
|
+
});
|
|
3411
|
+
let resultAttachment;
|
|
3412
|
+
let pullTx;
|
|
3413
|
+
let settledAmountSubunits;
|
|
3414
|
+
try {
|
|
3415
|
+
const result = await awaitJobResult(
|
|
3416
|
+
agent,
|
|
3417
|
+
{},
|
|
3418
|
+
({ resolve, reject }) => ({
|
|
3419
|
+
jobEventId: jobId,
|
|
3420
|
+
providerPubkey,
|
|
3421
|
+
customerPublicKey: agent.identity.publicKey,
|
|
3422
|
+
callbacks: {
|
|
3423
|
+
onResult(content, _eventId, attachment, _attachments, paymentTx, paidAmountSubunits) {
|
|
3424
|
+
pullTx = paymentTx;
|
|
3425
|
+
settledAmountSubunits = paidAmountSubunits;
|
|
3426
|
+
if (attachment) {
|
|
3427
|
+
resultAttachment = attachment;
|
|
3428
|
+
resolve(formatFileResultMetadata(jobId, attachment));
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
const sanitized = sanitizeResultContent(content);
|
|
3432
|
+
resolve(`Job completed.
|
|
3433
|
+
|
|
3434
|
+
${sanitized.text}`);
|
|
3435
|
+
},
|
|
3436
|
+
onError(error) {
|
|
3437
|
+
rejectWithProviderError(reject, error);
|
|
3438
|
+
},
|
|
3439
|
+
onTimeout(waitedMs) {
|
|
3440
|
+
reject(new JobWaitTimeoutError(waitedMs));
|
|
3441
|
+
}
|
|
3442
|
+
},
|
|
3443
|
+
timeoutMs,
|
|
3444
|
+
customerSecretKey: agent.identity.secretKey
|
|
3445
|
+
}),
|
|
3446
|
+
timeoutMs + 5e3
|
|
3447
|
+
);
|
|
3448
|
+
const settledForSession = settledSubunitsForHistory(card, price, settledAmountSubunits);
|
|
3449
|
+
recordSpend(ctx, delegationAsset, BigInt(settledForSession));
|
|
3450
|
+
await recordJobOutcome(agent, {
|
|
3451
|
+
jobEventId: jobId,
|
|
3452
|
+
capability: dTag,
|
|
3453
|
+
providerPubkey,
|
|
3454
|
+
providerName: clipProviderName(provider.name),
|
|
3455
|
+
// What actually moved, as recorded in the buyer's OWN history.
|
|
3456
|
+
//
|
|
3457
|
+
// On a FLAT card the runtime always pulls exactly `price`, so there is
|
|
3458
|
+
// nothing to learn from the provider and the reported figure is only a
|
|
3459
|
+
// way to lie - the card price is the truth by construction.
|
|
3460
|
+
//
|
|
3461
|
+
// On a METERED card the pull is variable, so the provider's report is
|
|
3462
|
+
// the only source of the real number. It is still attacker-controlled
|
|
3463
|
+
// and arrives through a tag parser that accepts negatives and
|
|
3464
|
+
// prefix-parses garbage, so it is trusted only inside the range the
|
|
3465
|
+
// card published and the buyer approved: `[min, price]`. Anything
|
|
3466
|
+
// outside degrades to the ceiling rather than to the attacker's value.
|
|
3467
|
+
paidAmountSubunits: String(settledForSession),
|
|
3468
|
+
// The registry key, not the card's. A mint-less `usdc` card is
|
|
3469
|
+
// accepted by the gate above but keys as `solana:usdc`, which
|
|
3470
|
+
// `assetByKey` cannot resolve and which splits any per-asset
|
|
3471
|
+
// aggregation away from the canonical `solana:usdc:<mint>` rows.
|
|
3472
|
+
assetKey: assetKey(delegationAsset),
|
|
3473
|
+
status: "completed",
|
|
3474
|
+
submittedAt,
|
|
3475
|
+
completedAt: Date.now(),
|
|
3476
|
+
resultPreview: result.slice(0, RESULT_PREVIEW_MAX_LEN),
|
|
3477
|
+
paymentSig: pullTx,
|
|
3478
|
+
attachmentJson: resultAttachment ? JSON.stringify(resultAttachment) : void 0
|
|
3479
|
+
});
|
|
3480
|
+
const pullLine = pullTx !== void 0 ? `pull_tx=${pullTx}
|
|
3481
|
+
` : "";
|
|
3482
|
+
const tip = buildJobCompletionTip(jobId, params.providerNpub);
|
|
3483
|
+
return textResult(`event_id=${jobId}
|
|
3484
|
+
${pullLine}${result}${tip}`);
|
|
3485
|
+
} catch (e) {
|
|
3486
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3487
|
+
const isTimeout = e instanceof JobWaitTimeoutError;
|
|
3488
|
+
await recordJobOutcome(agent, {
|
|
3489
|
+
jobEventId: jobId,
|
|
3490
|
+
capability: dTag,
|
|
3491
|
+
providerPubkey,
|
|
3492
|
+
providerName: clipProviderName(provider.name),
|
|
3493
|
+
status: isTimeout ? "timeout" : "failed",
|
|
3494
|
+
submittedAt,
|
|
3495
|
+
completedAt: Date.now()
|
|
3496
|
+
});
|
|
3497
|
+
if (isTimeout) {
|
|
3498
|
+
return textResult(
|
|
3499
|
+
`event_id=${jobId}
|
|
3500
|
+
Still processing (delegated: the provider pulls payment only after delivering the result). This is NOT an error - retry get_job_result with event_id="${jobId}" later. If the provider never starts, the proof expires within ${MAX_PROOF_TTL_SECS / 60} minutes with no charge.`
|
|
3501
|
+
);
|
|
3502
|
+
}
|
|
3503
|
+
const safeMsg = sanitizeUntrusted(msg, "text").text;
|
|
3504
|
+
return errorResult(
|
|
3505
|
+
`Job ${jobId} failed: ${safeMsg}. Delegated jobs charge only on delivery - no charge occurred unless a pull was reported.`
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3225
3509
|
function awaitJobResult(agent, options, fn, safetyTimeoutMs) {
|
|
3226
3510
|
return new Promise((resolve, reject) => {
|
|
3227
3511
|
let closeFn = null;
|
|
@@ -3330,233 +3614,59 @@ var customerTools = [
|
|
|
3330
3614
|
}),
|
|
3331
3615
|
defineTool({
|
|
3332
3616
|
name: "submit_delegated_job",
|
|
3333
|
-
description: "Submit a job paid from your existing spl-approve USDC delegation: the provider does the work FIRST, then pulls from your delegated allowance - no per-job payment transaction from you. On an ordinary capability it pulls the advertised price. On a METERED one (the card carries a `metered` block) the advertised price is a CEILING and the pull is what the job actually consumed, never more than that ceiling - so a metered card is usually cheaper here than its listed price suggests. Requires an ACTIVE delegation to the delegate key this capability advertises (check with get_delegation). Within the approved cap the delegate can pull without your signature, so treat the cap as the max loss. Your per-session spend limit also applies: the job is refused if its ceiling does not fit the remaining session budget. If max_price_lamports is not set, returns the price - or the range, when metered - for confirmation without publishing anything.",
|
|
3617
|
+
description: "Submit a job paid from your existing spl-approve USDC delegation: the provider does the work FIRST, then pulls from your delegated allowance - no per-job payment transaction from you. On an ordinary capability it pulls the advertised price. On a METERED one (the card carries a `metered` block) the advertised price is a CEILING and the pull is what the job actually consumed, never more than that ceiling - so a metered card is usually cheaper here than its listed price suggests. Requires an ACTIVE delegation to the delegate key this capability advertises (check with get_delegation). Within the approved cap the delegate can pull without your signature, so treat the cap as the max loss. Your per-session spend limit also applies: the job is refused if its ceiling does not fit the remaining session budget. An input too large to ride inline is sent via iroh automatically (needs a persistent agent); for a file on disk use submit_delegated_job_from_file. If max_price_lamports is not set, returns the price - or the range, when metered - for confirmation without publishing anything.",
|
|
3334
3618
|
schema: SubmitDelegatedJobSchema,
|
|
3335
3619
|
async handler(ctx, input) {
|
|
3336
3620
|
ctx.toolRateLimiter.check();
|
|
3337
|
-
checkLen("input", input.input, MAX_INPUT_LEN);
|
|
3338
3621
|
checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
|
|
3339
|
-
const
|
|
3340
|
-
if (
|
|
3341
|
-
return errorResult(
|
|
3342
|
-
"Solana wallet not configured for this agent - delegated payment signs the proof with the owner (wallet) key."
|
|
3343
|
-
);
|
|
3344
|
-
}
|
|
3345
|
-
const providerPubkey = decodeNpub(input.provider_npub);
|
|
3346
|
-
const dTag = toDTag(input.capability);
|
|
3347
|
-
const timeoutMs = Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3;
|
|
3348
|
-
const ping = await agent.client.ping.pingAgent(providerPubkey, PRE_PING_TIMEOUT_MS);
|
|
3349
|
-
if (!ping.online) {
|
|
3350
|
-
return errorResult(
|
|
3351
|
-
`Provider ${input.provider_npub} is offline. Run search_agents to find currently-online providers.`
|
|
3352
|
-
);
|
|
3353
|
-
}
|
|
3354
|
-
const providers = await agent.client.discovery.fetchAgents(agent.network);
|
|
3355
|
-
const provider = providers.find((candidate) => candidate.npub === input.provider_npub);
|
|
3356
|
-
if (!provider) {
|
|
3357
|
-
return errorResult(
|
|
3358
|
-
`Provider ${input.provider_npub} not found on ${agent.network}. Refresh discovery (e.g. search_agents) or verify the npub is correct.`
|
|
3359
|
-
);
|
|
3360
|
-
}
|
|
3361
|
-
const card = paymentCardForCapability(provider, dTag);
|
|
3362
|
-
const descriptor = card?.delegation;
|
|
3363
|
-
if (!descriptor) {
|
|
3364
|
-
return errorResult(
|
|
3365
|
-
`Capability "${input.capability}" of ${input.provider_npub} does not advertise delegated payment. Use submit_and_pay_job instead.`
|
|
3366
|
-
);
|
|
3367
|
-
}
|
|
3368
|
-
const { price, asset } = advertisedPriceForCapability(provider, dTag);
|
|
3369
|
-
if (price <= 0) {
|
|
3370
|
-
return errorResult(
|
|
3371
|
-
`Capability "${input.capability}" advertises no price - delegated payment needs a priced skill. Use create_job for free capabilities.`
|
|
3372
|
-
);
|
|
3373
|
-
}
|
|
3374
|
-
const delegationAsset = resolveUsdcAsset(agent.network);
|
|
3375
|
-
const cardPayment = paymentCardForCapability(provider, dTag)?.payment;
|
|
3376
|
-
const cardMint = cardPayment?.mint;
|
|
3377
|
-
const isCanonicalUsdc = (cardPayment?.chain ?? delegationAsset.chain) === delegationAsset.chain && cardPayment?.token?.toLowerCase() === delegationAsset.token && (cardMint === void 0 || cardMint === delegationAsset.mint);
|
|
3378
|
-
if (!isCanonicalUsdc) {
|
|
3379
|
-
const declared = cardPayment?.symbol ?? cardPayment?.token ?? asset.symbol;
|
|
3380
|
-
const { text } = sanitizeUntrusted(
|
|
3381
|
-
`Delegated payment is USDC-only, but this capability is priced in ${declared}.`,
|
|
3382
|
-
"text"
|
|
3383
|
-
);
|
|
3384
|
-
return errorResult(text);
|
|
3385
|
-
}
|
|
3386
|
-
const priceSubunits = BigInt(price);
|
|
3387
|
-
const rpc = createSolanaRpc(rpcUrlFor(agent.network));
|
|
3388
|
-
const ownerAta = await deriveOwnerDelegationAta(agent.solanaKeypair.publicKey, agent.network);
|
|
3389
|
-
let delegation;
|
|
3390
|
-
try {
|
|
3391
|
-
delegation = await getDelegation(rpc, ownerAta);
|
|
3392
|
-
} catch (e) {
|
|
3393
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
3394
|
-
return errorResult(`Could not read your delegation on-chain: ${message}`);
|
|
3395
|
-
}
|
|
3396
|
-
if (delegation === null || delegation.delegate === null) {
|
|
3397
|
-
return errorResult(
|
|
3398
|
-
"No active delegation from this wallet. Approve one first (web app Delegation tab, or the approve flow), then retry."
|
|
3399
|
-
);
|
|
3400
|
-
}
|
|
3401
|
-
if (delegation.delegate !== descriptor.delegate_pubkey) {
|
|
3402
|
-
const { text } = sanitizeUntrusted(
|
|
3403
|
-
`Your delegation is granted to ${delegation.delegate}, but this capability advertises delegate ${descriptor.delegate_pubkey} (the provider may have rotated its key). Re-approve the delegation, then retry.`,
|
|
3404
|
-
"text"
|
|
3405
|
-
);
|
|
3406
|
-
return errorResult(text);
|
|
3407
|
-
}
|
|
3408
|
-
if (delegation.remainingCap < priceSubunits) {
|
|
3409
|
-
return errorResult(
|
|
3410
|
-
`Remaining delegated cap ${formatAssetAmount(delegationAsset, delegation.remainingCap)} is below the price ${formatAssetAmount(delegationAsset, priceSubunits)}. Top up the delegation (re-approve) first.`
|
|
3411
|
-
);
|
|
3412
|
-
}
|
|
3413
|
-
if (delegation.balance < priceSubunits) {
|
|
3622
|
+
const inputBytes = utf8ByteLength(input.input);
|
|
3623
|
+
if (inputBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
|
|
3414
3624
|
return errorResult(
|
|
3415
|
-
`
|
|
3625
|
+
`Input is ${inputBytes} bytes (max ${LIMITS.MAX_REINLINE_TEXT_BYTES} for an inline job). Send a large file with submit_delegated_job_from_file.`
|
|
3416
3626
|
);
|
|
3417
3627
|
}
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
const priceGate = await confirmPriceGate({
|
|
3424
|
-
agent,
|
|
3425
|
-
providerLabel: sanitizeField(provider.name || input.provider_npub, 64),
|
|
3628
|
+
const agent = ctx.active();
|
|
3629
|
+
return executeDelegatedJob(ctx, agent, {
|
|
3630
|
+
// Large input spills to iroh transparently; small input stays inline.
|
|
3631
|
+
prepareInput: () => prepareTextInput(agent, input.input),
|
|
3632
|
+
providerNpub: input.provider_npub,
|
|
3426
3633
|
capability: input.capability,
|
|
3427
|
-
|
|
3428
|
-
|
|
3634
|
+
kindOffset: input.kind_offset,
|
|
3635
|
+
timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3,
|
|
3429
3636
|
maxPriceLamports: input.max_price_lamports,
|
|
3430
|
-
toolName: "submit_delegated_job"
|
|
3431
|
-
// Card-published floor, so the quote reads "from X, never more than Y"
|
|
3432
|
-
// instead of a flat number the buyer will usually not be charged.
|
|
3433
|
-
...card?.metered !== void 0 ? { meteredMinSubunits: BigInt(card.metered.min_subunits) } : {}
|
|
3637
|
+
toolName: "submit_delegated_job"
|
|
3434
3638
|
});
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3639
|
+
}
|
|
3640
|
+
}),
|
|
3641
|
+
defineTool({
|
|
3642
|
+
name: "submit_delegated_job_from_file",
|
|
3643
|
+
description: "Same as submit_delegated_job, but the job input is read from a file on disk by the MCP server and sent peer-to-peer via iroh - the file content never enters the model's output tokens. Prefer this over submit_and_pay_job_from_file whenever the capability advertises delegation: that tool pays the full listed price up front, while here the provider pulls from your delegation after delivering - on a METERED card only what the job consumed, never more than the listed price. Requires an ACTIVE delegation to the delegate key the capability advertises (check with get_delegation), a persistent agent, and the iroh addon. Text files reach the skill on stdin; binary files via ELISYM_INPUT_FILE. Pass an optional `prompt` to send a text instruction alongside the file; it rides inline (encrypted) while the file rides P2P. If max_price_lamports is not set, returns the price - or the range, when metered - for confirmation without publishing anything.",
|
|
3644
|
+
schema: SubmitDelegatedJobFromFileSchema,
|
|
3645
|
+
async handler(ctx, input) {
|
|
3646
|
+
ctx.toolRateLimiter.check();
|
|
3647
|
+
checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
|
|
3648
|
+
const validated = await validateFileInput(ctx, {
|
|
3649
|
+
inputPath: input.input_path,
|
|
3650
|
+
allowOutsideCwd: input.allow_outside_cwd,
|
|
3651
|
+
prompt: input.prompt
|
|
3448
3652
|
});
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3653
|
+
if ("error" in validated) {
|
|
3654
|
+
return errorResult(validated.error);
|
|
3655
|
+
}
|
|
3656
|
+
const { agent, prompt, file } = validated;
|
|
3657
|
+
return executeDelegatedJob(ctx, agent, {
|
|
3658
|
+
// Seeded lazily: the delegation pre-checks and price gate run first.
|
|
3659
|
+
prepareInput: async () => {
|
|
3660
|
+
const seeded = await seedFileAttachment(agent, file);
|
|
3661
|
+
return "error" in seeded ? seeded : { input: prompt, attachment: seeded.attachment };
|
|
3662
|
+
},
|
|
3663
|
+
providerNpub: input.provider_npub,
|
|
3664
|
+
capability: input.capability,
|
|
3454
3665
|
kindOffset: input.kind_offset,
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
expiryUnix,
|
|
3459
|
-
nonce,
|
|
3460
|
-
proof
|
|
3461
|
-
}
|
|
3666
|
+
timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3,
|
|
3667
|
+
maxPriceLamports: input.max_price_lamports,
|
|
3668
|
+
toolName: "submit_delegated_job_from_file"
|
|
3462
3669
|
});
|
|
3463
|
-
let resultAttachment;
|
|
3464
|
-
let pullTx;
|
|
3465
|
-
let settledAmountSubunits;
|
|
3466
|
-
try {
|
|
3467
|
-
const result = await awaitJobResult(
|
|
3468
|
-
agent,
|
|
3469
|
-
{},
|
|
3470
|
-
({ resolve, reject }) => ({
|
|
3471
|
-
jobEventId: jobId,
|
|
3472
|
-
providerPubkey,
|
|
3473
|
-
customerPublicKey: agent.identity.publicKey,
|
|
3474
|
-
callbacks: {
|
|
3475
|
-
onResult(content, _eventId, attachment, _attachments, paymentTx, paidAmountSubunits) {
|
|
3476
|
-
pullTx = paymentTx;
|
|
3477
|
-
settledAmountSubunits = paidAmountSubunits;
|
|
3478
|
-
if (attachment) {
|
|
3479
|
-
resultAttachment = attachment;
|
|
3480
|
-
resolve(formatFileResultMetadata(jobId, attachment));
|
|
3481
|
-
return;
|
|
3482
|
-
}
|
|
3483
|
-
const sanitized = sanitizeResultContent(content);
|
|
3484
|
-
resolve(`Job completed.
|
|
3485
|
-
|
|
3486
|
-
${sanitized.text}`);
|
|
3487
|
-
},
|
|
3488
|
-
onError(error) {
|
|
3489
|
-
rejectWithProviderError(reject, error);
|
|
3490
|
-
},
|
|
3491
|
-
onTimeout(waitedMs) {
|
|
3492
|
-
reject(new JobWaitTimeoutError(waitedMs));
|
|
3493
|
-
}
|
|
3494
|
-
},
|
|
3495
|
-
timeoutMs,
|
|
3496
|
-
customerSecretKey: agent.identity.secretKey
|
|
3497
|
-
}),
|
|
3498
|
-
timeoutMs + 5e3
|
|
3499
|
-
);
|
|
3500
|
-
const settledForSession = settledSubunitsForHistory(card, price, settledAmountSubunits);
|
|
3501
|
-
recordSpend(ctx, delegationAsset, BigInt(settledForSession));
|
|
3502
|
-
await recordJobOutcome(agent, {
|
|
3503
|
-
jobEventId: jobId,
|
|
3504
|
-
capability: dTag,
|
|
3505
|
-
providerPubkey,
|
|
3506
|
-
providerName: clipProviderName(provider.name),
|
|
3507
|
-
// What actually moved, as recorded in the buyer's OWN history.
|
|
3508
|
-
//
|
|
3509
|
-
// On a FLAT card the runtime always pulls exactly `price`, so there is
|
|
3510
|
-
// nothing to learn from the provider and the reported figure is only a
|
|
3511
|
-
// way to lie - the card price is the truth by construction.
|
|
3512
|
-
//
|
|
3513
|
-
// On a METERED card the pull is variable, so the provider's report is
|
|
3514
|
-
// the only source of the real number. It is still attacker-controlled
|
|
3515
|
-
// and arrives through a tag parser that accepts negatives and
|
|
3516
|
-
// prefix-parses garbage, so it is trusted only inside the range the
|
|
3517
|
-
// card published and the buyer approved: `[min, price]`. Anything
|
|
3518
|
-
// outside degrades to the ceiling rather than to the attacker's value.
|
|
3519
|
-
paidAmountSubunits: String(settledForSession),
|
|
3520
|
-
// The registry key, not the card's. A mint-less `usdc` card is
|
|
3521
|
-
// accepted by the gate above but keys as `solana:usdc`, which
|
|
3522
|
-
// `assetByKey` cannot resolve and which splits any per-asset
|
|
3523
|
-
// aggregation away from the canonical `solana:usdc:<mint>` rows.
|
|
3524
|
-
assetKey: assetKey(delegationAsset),
|
|
3525
|
-
status: "completed",
|
|
3526
|
-
submittedAt,
|
|
3527
|
-
completedAt: Date.now(),
|
|
3528
|
-
resultPreview: result.slice(0, RESULT_PREVIEW_MAX_LEN),
|
|
3529
|
-
paymentSig: pullTx,
|
|
3530
|
-
attachmentJson: resultAttachment ? JSON.stringify(resultAttachment) : void 0
|
|
3531
|
-
});
|
|
3532
|
-
const pullLine = pullTx !== void 0 ? `pull_tx=${pullTx}
|
|
3533
|
-
` : "";
|
|
3534
|
-
const tip = buildJobCompletionTip(jobId, input.provider_npub);
|
|
3535
|
-
return textResult(`event_id=${jobId}
|
|
3536
|
-
${pullLine}${result}${tip}`);
|
|
3537
|
-
} catch (e) {
|
|
3538
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
3539
|
-
const isTimeout = e instanceof JobWaitTimeoutError;
|
|
3540
|
-
await recordJobOutcome(agent, {
|
|
3541
|
-
jobEventId: jobId,
|
|
3542
|
-
capability: dTag,
|
|
3543
|
-
providerPubkey,
|
|
3544
|
-
providerName: clipProviderName(provider.name),
|
|
3545
|
-
status: isTimeout ? "timeout" : "failed",
|
|
3546
|
-
submittedAt,
|
|
3547
|
-
completedAt: Date.now()
|
|
3548
|
-
});
|
|
3549
|
-
if (isTimeout) {
|
|
3550
|
-
return textResult(
|
|
3551
|
-
`event_id=${jobId}
|
|
3552
|
-
Still processing (delegated: the provider pulls payment only after delivering the result). This is NOT an error - retry get_job_result with event_id="${jobId}" later. If the provider never starts, the proof expires within ${MAX_PROOF_TTL_SECS / 60} minutes with no charge.`
|
|
3553
|
-
);
|
|
3554
|
-
}
|
|
3555
|
-
const safeMsg = sanitizeUntrusted(msg, "text").text;
|
|
3556
|
-
return errorResult(
|
|
3557
|
-
`Job ${jobId} failed: ${safeMsg}. Delegated jobs charge only on delivery - no charge occurred unless a pull was reported.`
|
|
3558
|
-
);
|
|
3559
|
-
}
|
|
3560
3670
|
}
|
|
3561
3671
|
}),
|
|
3562
3672
|
defineTool({
|
|
@@ -3915,7 +4025,7 @@ ${text}`);
|
|
|
3915
4025
|
}),
|
|
3916
4026
|
defineTool({
|
|
3917
4027
|
name: "submit_and_pay_job",
|
|
3918
|
-
description: 'Full customer flow: submit job -> auto-pay -> wait for result. Validates that the payment recipient matches the provider card. If payment succeeded but no result arrives within the wait window, this returns a non-error "still processing" notice with the event ID (NOT a failure) - re-poll get_job_result later (results persist on the relays; for long jobs, poll periodically, e.g. from a subagent). Handles both free and paid providers automatically. If max_price_lamports is not set and the capability is paid, this returns the advertised price for confirmation WITHOUT submitting a job - re-call with max_price_lamports set to approve payments up to that limit (this is a confirmation, not an error). COST: input is sent inline in the tool call, so a large input pays output tokens on the calling LLM. For files or git diffs, prefer submit_and_pay_job_from_file or submit_diff_review respectively.',
|
|
4028
|
+
description: 'Full customer flow: submit job -> auto-pay -> wait for result. Validates that the payment recipient matches the provider card. If payment succeeded but no result arrives within the wait window, this returns a non-error "still processing" notice with the event ID (NOT a failure) - re-poll get_job_result later (results persist on the relays; for long jobs, poll periodically, e.g. from a subagent). Handles both free and paid providers automatically. If max_price_lamports is not set and the capability is paid, this returns the advertised price for confirmation WITHOUT submitting a job - re-call with max_price_lamports set to approve payments up to that limit (this is a confirmation, not an error). COST: input is sent inline in the tool call, so a large input pays output tokens on the calling LLM. For files or git diffs, prefer submit_and_pay_job_from_file or submit_diff_review respectively. This pays the listed price up front; when the capability advertises delegation, submit_delegated_job bills a metered card for actual usage instead.',
|
|
3919
4029
|
schema: SubmitAndPayJobSchema,
|
|
3920
4030
|
async handler(ctx, input) {
|
|
3921
4031
|
ctx.toolRateLimiter.check();
|
|
@@ -3954,53 +4064,26 @@ ${text}`);
|
|
|
3954
4064
|
}),
|
|
3955
4065
|
defineTool({
|
|
3956
4066
|
name: "submit_and_pay_job_from_file",
|
|
3957
|
-
description: "Same as submit_and_pay_job, but the job input is read from a file on disk by the MCP server instead of being passed inline by the LLM. Use this when the input is large or binary (images, logs, captured output) and the LLM only needs to forward it - the file content never enters the model's output tokens. input_path may be absolute or relative to the MCP server's working directory. The file is ALWAYS transferred peer-to-peer via iroh, so this needs: a persistent agent, a PAID provider skill (free skills reject file inputs), and the iroh addon. Text files reach the skill on stdin; binary files via ELISYM_INPUT_FILE. Pass an optional `prompt` to send a text instruction alongside the file (e.g. how to edit an image); it rides inline (encrypted) while the file rides P2P.",
|
|
4067
|
+
description: "Same as submit_and_pay_job, but the job input is read from a file on disk by the MCP server instead of being passed inline by the LLM. Use this when the input is large or binary (images, logs, captured output) and the LLM only needs to forward it - the file content never enters the model's output tokens. input_path may be absolute or relative to the MCP server's working directory. The file is ALWAYS transferred peer-to-peer via iroh, so this needs: a persistent agent, a PAID provider skill (free skills reject file inputs), and the iroh addon. Text files reach the skill on stdin; binary files via ELISYM_INPUT_FILE. Pass an optional `prompt` to send a text instruction alongside the file (e.g. how to edit an image); it rides inline (encrypted) while the file rides P2P. This pays the listed price up front; when the capability advertises delegation, use submit_delegated_job_from_file so a metered card bills only actual usage.",
|
|
3958
4068
|
schema: SubmitAndPayJobFromFileSchema,
|
|
3959
4069
|
async handler(ctx, input) {
|
|
3960
4070
|
ctx.toolRateLimiter.check();
|
|
3961
4071
|
checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
|
|
3962
|
-
const
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
let prepared;
|
|
3970
|
-
try {
|
|
3971
|
-
prepared = await prepareFileInput(input.input_path, {
|
|
3972
|
-
allowOutsideCwd: input.allow_outside_cwd
|
|
3973
|
-
});
|
|
3974
|
-
} catch (e) {
|
|
3975
|
-
return errorResult(e instanceof Error ? e.message : String(e));
|
|
4072
|
+
const validated = await validateFileInput(ctx, {
|
|
4073
|
+
inputPath: input.input_path,
|
|
4074
|
+
allowOutsideCwd: input.allow_outside_cwd,
|
|
4075
|
+
prompt: input.prompt
|
|
4076
|
+
});
|
|
4077
|
+
if ("error" in validated) {
|
|
4078
|
+
return errorResult(validated.error);
|
|
3976
4079
|
}
|
|
3977
|
-
const
|
|
3978
|
-
if (
|
|
3979
|
-
return errorResult(
|
|
3980
|
-
`Sending a file requires a persistent agent (this is an ephemeral session). Files are always transferred P2P via iroh, never inline.`
|
|
3981
|
-
);
|
|
4080
|
+
const seeded = await seedFileAttachment(validated.agent, validated.file);
|
|
4081
|
+
if ("error" in seeded) {
|
|
4082
|
+
return errorResult(seeded.error);
|
|
3982
4083
|
}
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
attachment = {
|
|
3987
|
-
name: prepared.name,
|
|
3988
|
-
size: seeded.size,
|
|
3989
|
-
mime: prepared.mime,
|
|
3990
|
-
transports: [{ kind: "iroh", ticket: seeded.ticket }]
|
|
3991
|
-
};
|
|
3992
|
-
} catch (e) {
|
|
3993
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
3994
|
-
if (/@number0\/iroh|iroh file transfer is unavailable/i.test(msg)) {
|
|
3995
|
-
return errorResult(
|
|
3996
|
-
`File transfer is unavailable: the optional @number0/iroh addon is not installed. Install it (e.g. \`bun add @number0/iroh\`) to send files.`
|
|
3997
|
-
);
|
|
3998
|
-
}
|
|
3999
|
-
return errorResult(`Failed to seed file for transfer: ${msg}`);
|
|
4000
|
-
}
|
|
4001
|
-
return executeSubmitAndPay(ctx, agent, {
|
|
4002
|
-
input: trimmedPrompt,
|
|
4003
|
-
attachment,
|
|
4084
|
+
return executeSubmitAndPay(ctx, validated.agent, {
|
|
4085
|
+
input: validated.prompt,
|
|
4086
|
+
attachment: seeded.attachment,
|
|
4004
4087
|
providerNpub: input.provider_npub,
|
|
4005
4088
|
providerPubkey: decodeNpub(input.provider_npub),
|
|
4006
4089
|
capability: input.capability,
|
|
@@ -4727,11 +4810,11 @@ var discoveryTools = [
|
|
|
4727
4810
|
// Metered pricing: `job_price` above is then the CEILING, and the
|
|
4728
4811
|
// real charge lands between the floor and it. Surfaced so a buying
|
|
4729
4812
|
// model does not read the ceiling as a flat rate and skip a card
|
|
4730
|
-
// that is usually far cheaper. Only ever reachable through
|
|
4731
|
-
//
|
|
4732
|
-
//
|
|
4813
|
+
// that is usually far cheaper. Only ever reachable through the
|
|
4814
|
+
// delegated tools - the ordinary paid path settles up front and
|
|
4815
|
+
// always collects the ceiling.
|
|
4733
4816
|
// Gated on `delegation` too: metering is only reachable through
|
|
4734
|
-
//
|
|
4817
|
+
// the delegated tools, which refuse a card with no delegation
|
|
4735
4818
|
// descriptor outright. Advertising "billed for actual usage
|
|
4736
4819
|
// (requires delegated payment)" on such a card would point a
|
|
4737
4820
|
// buying model at a door that is bolted shut.
|
|
@@ -4747,7 +4830,8 @@ var discoveryTools = [
|
|
|
4747
4830
|
network: card.payment?.network,
|
|
4748
4831
|
network_fee_estimate_sol: gasEstimate,
|
|
4749
4832
|
// File-exchange hints (dynamic-script). Informational: the MCP/CLI
|
|
4750
|
-
// CAN send files via submit_and_pay_job_from_file
|
|
4833
|
+
// CAN send files via submit_and_pay_job_from_file and
|
|
4834
|
+
// submit_delegated_job_from_file, so this does not
|
|
4751
4835
|
// gate anything - it just tells the caller a file input is expected.
|
|
4752
4836
|
// Already length-bounded by parseCapabilityEvent.
|
|
4753
4837
|
...card.inputMime ? { input_mime: card.inputMime } : {},
|
|
@@ -7205,7 +7289,7 @@ program.action(
|
|
|
7205
7289
|
}
|
|
7206
7290
|
const client = new ElisymClient({ relays: RELAYS });
|
|
7207
7291
|
const name = process.env.ELISYM_AGENT_NAME ?? "mcp-agent";
|
|
7208
|
-
const network = envNetwork ??
|
|
7292
|
+
const network = envNetwork ?? DEFAULT_NETWORK;
|
|
7209
7293
|
ctx.register({ client, identity, name, network, security: {} });
|
|
7210
7294
|
console.error(`Ephemeral agent: ${name} (${network})`);
|
|
7211
7295
|
} else {
|
|
@@ -7225,7 +7309,7 @@ program.action(
|
|
|
7225
7309
|
} else {
|
|
7226
7310
|
const identity = ElisymIdentity.generate();
|
|
7227
7311
|
const client = new ElisymClient({ relays: RELAYS });
|
|
7228
|
-
const network = envNetwork ??
|
|
7312
|
+
const network = envNetwork ?? DEFAULT_NETWORK;
|
|
7229
7313
|
ctx.register({ client, identity, name: "mcp-agent", network, security: {} });
|
|
7230
7314
|
console.error(`Created ephemeral agent (no persistent identity, ${network}).`);
|
|
7231
7315
|
}
|
|
@@ -7235,8 +7319,8 @@ program.action(
|
|
|
7235
7319
|
);
|
|
7236
7320
|
program.command("init [name]").description("Create a new agent identity").option("-d, --description <desc>", "Agent description", "Elisym MCP agent").option(
|
|
7237
7321
|
"-n, --network <network>",
|
|
7238
|
-
|
|
7239
|
-
|
|
7322
|
+
`Solana network (devnet or mainnet). Fixed at creation: to change networks later, create a new agent. Defaults to ${DEFAULT_NETWORK}; mainnet payments move real funds.`,
|
|
7323
|
+
DEFAULT_NETWORK
|
|
7240
7324
|
).option("--install", "Also install into MCP clients").option(
|
|
7241
7325
|
"--passphrase <value>",
|
|
7242
7326
|
'Passphrase to encrypt secret keys at rest. Empty string ("") skips encryption. Also reads ELISYM_PASSPHRASE env var. When neither is provided, prompts interactively.'
|
|
@@ -7262,7 +7346,7 @@ program.command("init [name]").description("Create a new agent identity").option
|
|
|
7262
7346
|
name: "network",
|
|
7263
7347
|
message: "Solana network (fixed at creation - create a new agent to change it):",
|
|
7264
7348
|
choices: ["devnet", "mainnet"],
|
|
7265
|
-
default:
|
|
7349
|
+
default: DEFAULT_NETWORK
|
|
7266
7350
|
}
|
|
7267
7351
|
]);
|
|
7268
7352
|
name = answers.name;
|