agent402-client 0.8.2 → 0.8.4
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 +89 -16
- package/index.js +169 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,8 +5,10 @@ instance) - the buy side of [Agentic Finance](https://agent402.tools/agentic-fin
|
|
|
5
5
|
agents paying per request over x402 or MPP. **Resolve a task to a tool, then call it - with payment handled for
|
|
6
6
|
you.** Free pure-CPU tools settle with a built-in proof-of-work (no wallet, zero
|
|
7
7
|
dependencies); wallet-only tools settle via an x402- or MPP-wrapped fetch you
|
|
8
|
-
provide, or by card through a prepaid credits key. Results are cached, and
|
|
9
|
-
|
|
8
|
+
provide, or by card through a prepaid credits key. Results are cached, and every
|
|
9
|
+
send carries an `Idempotency-Key` that is stable per client and per operation, so
|
|
10
|
+
a retried lost response replays the paid answer on the credits and proof-of-work
|
|
11
|
+
paths (the wallet path is different: see "Retries and double charges").
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
npm install agent402-client
|
|
@@ -89,14 +91,32 @@ credits call reserves its price like a wallet call). A payment `fetch` still win
|
|
|
89
91
|
when both are given, and free pure-CPU tools keep settling with proof-of-work.
|
|
90
92
|
The same Bearer header also reads the balance directly, e.g. `curl -H "Authorization: Bearer a402_..." https://agent402.tools/api/credits/balance`.
|
|
91
93
|
|
|
92
|
-
## Retries
|
|
93
|
-
|
|
94
|
-
Every
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
## Retries and double charges
|
|
95
|
+
|
|
96
|
+
Every send carries an `Idempotency-Key`. By default it is derived from the
|
|
97
|
+
client instance plus the operation (slug, params, output validator), so it is
|
|
98
|
+
the same key when the SDK retries inside one `call()` AND when your framework
|
|
99
|
+
calls `client.call(slug, params)` again after a timeout or a dropped connection.
|
|
100
|
+
With `{ cache: false }` on the call, identical calls are treated as distinct
|
|
101
|
+
purchases and the key is fresh per invocation. Pass `idempotencyKey` yourself to
|
|
102
|
+
override either behavior. Keys are scoped to the client instance: two processes
|
|
103
|
+
buying the same thing are two purchases.
|
|
104
|
+
|
|
105
|
+
What the server does with it depends on how the call was paid:
|
|
106
|
+
|
|
107
|
+
- **Prepaid credits and proof-of-work:** the server binds the key to the credits
|
|
108
|
+
key (or the accepted solution) plus the route and body, and replays the
|
|
109
|
+
original 200 for ten minutes. A retry of a lost response is not debited again.
|
|
110
|
+
- **x402 wallet:** the server binds the key to the exact signed authorization.
|
|
111
|
+
An exact retry (same signed header, same key) replays without a second
|
|
112
|
+
settlement, and that is what the `payment-identifier` extension gives a stock
|
|
113
|
+
x402 client. A fresh `call()` through an `@x402/fetch`-wrapped fetch signs a
|
|
114
|
+
fresh authorization, which the server deliberately treats as a new payment
|
|
115
|
+
(a client-chosen id on an unverified payload is never a cross-authorization
|
|
116
|
+
dedupe). So on the wallet path a retry after a lost response can settle twice;
|
|
117
|
+
the `Idempotency-Key` cannot prevent that on its own. If that matters for your
|
|
118
|
+
workload, use a credits key for the tools you retry, or keep the signed
|
|
119
|
+
request and resend it yourself.
|
|
100
120
|
|
|
101
121
|
## Workflows (skill packs)
|
|
102
122
|
|
|
@@ -127,7 +147,8 @@ const { query, include, results } = await a.route(task, {
|
|
|
127
147
|
include: "external", // all | external | local
|
|
128
148
|
network: "robinhood", // optional chain filter (short name or CAIP-2)
|
|
129
149
|
});
|
|
130
|
-
// results[]: opaque server rows - { seller, slug, url, priceUsd,
|
|
150
|
+
// results[]: opaque server rows - { seller, slug, url, priceUsd, routerDispatchEligible, routerDispatchReason,
|
|
151
|
+
// executeVia? (only when executeViaCallableNow is true) | executeViaWhenEligible?, ... }
|
|
131
152
|
|
|
132
153
|
// executeVia quotes which route-execute* tier fits a row's underlying price.
|
|
133
154
|
// It does NOT bind that row: the tier re-resolves an eligible match under its
|
|
@@ -165,13 +186,13 @@ await a.topSellers({ sort: "calls", include: "all" });
|
|
|
165
186
|
|
|
166
187
|
| Method | What |
|
|
167
188
|
|---|---|
|
|
168
|
-
| `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed |
|
|
189
|
+
| `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes?, outputValidator? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed; `outputValidator` binds a caller-supplied delivery check |
|
|
169
190
|
| `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools on **this host** (route, price, schema, example) |
|
|
170
191
|
| `await a.route(task, { k?, include?, network? })` | Cross-seller Smart Order Router: rank eligible/routable seller rows from the host's current index (free, read-only; opaque results may include server-reported `executeVia`) |
|
|
171
192
|
| `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
|
|
172
193
|
| `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
|
|
173
194
|
| `await a.topSellers({ limit?, sort?, include? })` | Live x402 leaderboard: which sellers are settling the most USDC (primarily on Base) in the last ~24h (free, no payment) |
|
|
174
|
-
| `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
|
|
195
|
+
| `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes?, outputValidator? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
|
|
175
196
|
| `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
|
|
176
197
|
| `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
|
|
177
198
|
| `a.clearCache()` | Drop the in-memory result cache |
|
|
@@ -196,6 +217,56 @@ An oversized body throws `ResponseTooLargeError` carrying `size`, `cap`,
|
|
|
196
217
|
`source` and `paid`. Check `paid`: on a wallet-only tool the money moved before
|
|
197
218
|
the body arrived, so a refused response is still a spend you made.
|
|
198
219
|
|
|
220
|
+
## Buyer-owned output validation
|
|
221
|
+
|
|
222
|
+
A successful payment and HTTP 200 do not prove that the delivered result is
|
|
223
|
+
useful. Supply a stable contract id and a local callback to enforce the exact
|
|
224
|
+
output your agent needs:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
const a = new Agent402({
|
|
228
|
+
fetch: payFetch,
|
|
229
|
+
outputValidator: {
|
|
230
|
+
id: "unemployment-result/v1",
|
|
231
|
+
validate: (body) =>
|
|
232
|
+
typeof body.current === "number" &&
|
|
233
|
+
Array.isArray(body.history) &&
|
|
234
|
+
typeof body.source === "string",
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`validate` may return `false` or throw to reject delivery, and may be async.
|
|
240
|
+
Use any local validator you prefer: Ajv, Zod, agent-payment-policy, or ordinary
|
|
241
|
+
application code. The client adds no dependency and never sends the validator
|
|
242
|
+
or its id to the seller.
|
|
243
|
+
|
|
244
|
+
Validation runs after bounded JSON parsing and before cache admission. A
|
|
245
|
+
rejected paid response throws `OutputValidationError` with `paid: true`, stays
|
|
246
|
+
in `spendingSummary()`, and is never cached. Contracted cache entries are
|
|
247
|
+
namespaced by a SHA-256 digest of `id` and revalidated on every hit, so a
|
|
248
|
+
different contract or a caller-mutated cached object cannot bypass the check.
|
|
249
|
+
The caller owns the meaning and stability of `id`; changing validation
|
|
250
|
+
semantics requires a new id. A per-call validator may replace the constructor
|
|
251
|
+
validator, but `null` or omission preserves the constructor policy rather than
|
|
252
|
+
silently weakening it. Use a separate client when a route intentionally has no
|
|
253
|
+
output contract.
|
|
254
|
+
|
|
255
|
+
**Two costs worth knowing.** `validate` is awaited inside `call()`, so it is
|
|
256
|
+
bounded: a validator that has not settled in **5 seconds** rejects delivery with
|
|
257
|
+
`OutputValidationError` rather than hanging the call. On a paid route the money
|
|
258
|
+
has already moved by then, so an indefinite hang would be the worst place to
|
|
259
|
+
have one; a timeout is a rejection, because an unfinished contract is not a
|
|
260
|
+
satisfied one. Override per validator with `timeoutMs`:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
outputValidator: { id: "big-schema/v1", validate: slowCheck, timeoutMs: 30_000 }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
And because contracted cache entries are revalidated on **every** hit, an
|
|
267
|
+
expensive validator is paid for on cache hits too, not just on the network call.
|
|
268
|
+
Keep it cheap, or accept that cost knowingly.
|
|
269
|
+
|
|
199
270
|
## Spending caps (never overpay)
|
|
200
271
|
|
|
201
272
|
By default the client pays whatever a tool costs. Set optional hard ceilings and a
|
|
@@ -219,9 +290,11 @@ try {
|
|
|
219
290
|
}
|
|
220
291
|
```
|
|
221
292
|
|
|
222
|
-
Only **settled** paid calls count against the rolling window
|
|
223
|
-
|
|
224
|
-
|
|
293
|
+
Only **settled** paid calls count against the rolling window. A call refused or
|
|
294
|
+
failed before settlement never consumes budget. A paid HTTP-success response
|
|
295
|
+
that later fails byte, JSON, or output validation remains settled spend. Free
|
|
296
|
+
proof-of-work calls are never counted. Omit a cap (or leave it `null`) for no
|
|
297
|
+
limit; with none set, behavior is unchanged.
|
|
225
298
|
|
|
226
299
|
**What the caps check.** When a cap is set, the client preflights the `402` and
|
|
227
300
|
checks the ceiling against the **larger** of the advertised price (from the
|
package/index.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// instance). Resolve a task to a tool, then call it with payment handled for you:
|
|
3
3
|
// - free pure-CPU tools settle with a built-in proof-of-work (no wallet, zero deps),
|
|
4
4
|
// - wallet-only tools settle via an x402-wrapped fetch you provide (@x402/fetch),
|
|
5
|
-
// results are cached (tools are deterministic), and
|
|
6
|
-
// Idempotency-Key
|
|
5
|
+
// results are cached (tools are deterministic), and every send carries an
|
|
6
|
+
// Idempotency-Key that is stable per client instance and per operation while
|
|
7
|
+
// caching is on, so a retry of a lost response (inside call() or by the caller
|
|
8
|
+
// invoking call() again) replays the paid answer on the credits and PoW paths
|
|
9
|
+
// instead of paying twice. A wallet buyer's fresh call() signs a fresh
|
|
10
|
+
// authorization, which the server treats as a new payment by design - see
|
|
11
|
+
// README "Retries and double charges".
|
|
7
12
|
//
|
|
8
13
|
// import { Agent402 } from "agent402-client";
|
|
9
14
|
// const a = new Agent402(); // free tier, proof-of-work
|
|
@@ -12,18 +17,19 @@
|
|
|
12
17
|
//
|
|
13
18
|
// // paid tools: pass an x402-wrapped fetch (your wallet signs)
|
|
14
19
|
// const a = new Agent402({ fetch: payFetch });
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
20
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
21
|
|
|
17
22
|
// Keep in lockstep with package.json. Every request the SDK issues carries
|
|
18
23
|
// `User-Agent: agent402-client/<version>` - a standard header, no extra
|
|
19
24
|
// network calls - so a seller can attribute traffic (and settled payments)
|
|
20
25
|
// to this SDK. Product token only; nothing about the caller rides along.
|
|
21
|
-
const VERSION = "0.8.
|
|
26
|
+
const VERSION = "0.8.4";
|
|
22
27
|
const USER_AGENT = `agent402-client/${VERSION}`;
|
|
23
28
|
// 32MB: about a hundred times any realistic response from this catalog (the
|
|
24
29
|
// largest is a base64 image at a few MB), so it cannot break a legitimate
|
|
25
30
|
// caller, while still stopping the class it exists for. A ceiling that is off
|
|
26
31
|
// by default protects nobody, so this one is on; pass null to disable it.
|
|
32
|
+
const FAILURE_DETAIL_MAX_CHARS = 2_000;
|
|
27
33
|
const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
28
34
|
|
|
29
35
|
const leadingZeroBits = (buf) => { let n = 0; for (const b of buf) { if (b === 0) { n += 8; continue; } n += Math.clz32(b) - 24; break; } return n; };
|
|
@@ -42,10 +48,13 @@ export class Agent402 {
|
|
|
42
48
|
* pays wallet-only tools by card when no payFetch is given
|
|
43
49
|
* @param {number|null} [opts.maxResponseBytes=33554432]
|
|
44
50
|
* Hard ceiling on a response body, enforced BEFORE it is parsed. null disables it.
|
|
51
|
+
* @param {{id:string, validate:function}} [opts.outputValidator]
|
|
52
|
+
* Optional buyer-owned result validator. `id` namespaces cache entries;
|
|
53
|
+
* `validate` may return false or throw to reject delivery.
|
|
45
54
|
*/
|
|
46
55
|
constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
|
|
47
56
|
maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
|
|
48
|
-
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES } = {}) {
|
|
57
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null } = {}) {
|
|
49
58
|
this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
|
|
50
59
|
if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
|
|
51
60
|
this.baseUrl = String(baseUrl).replace(/\/$/, "");
|
|
@@ -55,6 +64,14 @@ export class Agent402 {
|
|
|
55
64
|
this.f = (url, init = {}) => fetchImpl(url, { ...init, headers: { "User-Agent": USER_AGENT, ...(init.headers || {}) } });
|
|
56
65
|
this._catalog = null;
|
|
57
66
|
this._cache = cache ? new Map() : null;
|
|
67
|
+
// Salt for the default Idempotency-Key: one per client instance, so the key
|
|
68
|
+
// for (slug, params) is stable across call() invocations on THIS client
|
|
69
|
+
// (an agent framework retrying a lost response gets the replay) and still
|
|
70
|
+
// distinct between sessions (two processes buying the same thing are two
|
|
71
|
+
// purchases). Issue #1126: the old default mixed Date.now() + Math.random()
|
|
72
|
+
// per invocation, which protected retries inside one call() and nothing
|
|
73
|
+
// the header promised beyond it.
|
|
74
|
+
this._idemSalt = randomBytes(12).toString("hex");
|
|
58
75
|
// Spending policy (defends the x402 "wallet drain via uncapped spending"
|
|
59
76
|
// failure mode): optional hard ceilings enforced BEFORE any payment is
|
|
60
77
|
// signed. A malicious or misconfigured 402 that quotes an inflated price is
|
|
@@ -69,6 +86,9 @@ export class Agent402 {
|
|
|
69
86
|
};
|
|
70
87
|
// A response-size ceiling, enforced before parsing. See _readJson.
|
|
71
88
|
this.maxResponseBytes = maxResponseBytes === null ? null : (numOrNull(maxResponseBytes) ?? DEFAULT_MAX_RESPONSE_BYTES);
|
|
89
|
+
// Buyer-owned delivery policy. The callback stays entirely local and may
|
|
90
|
+
// use Ajv, Zod, agent-payment-policy, or ordinary application code.
|
|
91
|
+
this.outputValidator = normalizeOutputValidator(outputValidator, "constructor");
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
/**
|
|
@@ -266,15 +286,41 @@ export class Agent402 {
|
|
|
266
286
|
* Call a tool by slug; pays automatically (PoW for free tools, x402 for
|
|
267
287
|
* wallet-only) and returns the parsed JSON result.
|
|
268
288
|
*/
|
|
269
|
-
async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes } = {}) {
|
|
289
|
+
async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes, outputValidator } = {}) {
|
|
290
|
+
// Resolve and validate the buyer-owned contract before catalog fetch,
|
|
291
|
+
// credential access, payment preflight, or any other network action.
|
|
292
|
+
const validator = outputValidator == null
|
|
293
|
+
? this.outputValidator
|
|
294
|
+
: normalizeOutputValidator(outputValidator, "call");
|
|
270
295
|
const cat = await this._loadCatalog();
|
|
271
296
|
const tool = cat.get(slug);
|
|
272
297
|
if (!tool) throw new Error(`unknown tool "${slug}" - use client.find(task) to discover one`);
|
|
273
298
|
|
|
274
|
-
const cacheKey =
|
|
275
|
-
if (this._cache && cache && this._cache.has(cacheKey))
|
|
299
|
+
const cacheKey = resultCacheKey(slug, params, validator);
|
|
300
|
+
if (this._cache && cache && this._cache.has(cacheKey)) {
|
|
301
|
+
const stored = this._cache.get(cacheKey);
|
|
302
|
+
// Callers receive the stored object by reference and may mutate it.
|
|
303
|
+
// Revalidate contracted hits so a now-invalid object cannot pass merely
|
|
304
|
+
// because it was valid before the caller changed it.
|
|
305
|
+
try {
|
|
306
|
+
await this._assertOutput(slug, stored, validator, { paid: false, cacheHit: true });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
this._cache.delete(cacheKey);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
return stored;
|
|
312
|
+
}
|
|
276
313
|
|
|
277
|
-
|
|
314
|
+
// Default key = stable per (client instance, slug, params, validator) while
|
|
315
|
+
// caching is on: the SDK already treats that tuple as one operation (a
|
|
316
|
+
// second identical call is served from cache without payment), so the
|
|
317
|
+
// idempotency key agrees with the cache key instead of contradicting it.
|
|
318
|
+
// With { cache: false } the caller has said identical calls are distinct
|
|
319
|
+
// purchases, so the key is fresh per invocation, as before. An explicit
|
|
320
|
+
// idempotencyKey always wins.
|
|
321
|
+
const idem = idempotencyKey || (cache
|
|
322
|
+
? `a402-${createHash("sha256").update(`${this._idemSalt}:${cacheKey}`).digest("hex").slice(0, 24)}`
|
|
323
|
+
: `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`);
|
|
278
324
|
const send = (extraHeaders = {}, useFetch = this.f) => {
|
|
279
325
|
// UA set here too (not only in the this.f wrapper) so the x402 payFetch
|
|
280
326
|
// path - the one that settles real payments - always carries it.
|
|
@@ -317,13 +363,17 @@ export class Agent402 {
|
|
|
317
363
|
// can't each observe the pre-commit total and collectively blow a rolling
|
|
318
364
|
// cap; release the reservation if the call doesn't settle.
|
|
319
365
|
const reservation = this._spendReserve(host, usd, slug);
|
|
366
|
+
let settled = false;
|
|
320
367
|
try {
|
|
321
368
|
const r = await send({}, this.payFetch);
|
|
322
|
-
if (!r.ok) throw new Error(
|
|
369
|
+
if (!r.ok) throw new Error(await this._failureDetail(slug, r));
|
|
323
370
|
this._spendSettle(reservation); // confirm the reservation as settled spend
|
|
324
|
-
|
|
371
|
+
settled = true;
|
|
372
|
+
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
325
373
|
} catch (e) {
|
|
326
|
-
|
|
374
|
+
// An invalid or oversized paid HTTP-success body is failed delivery,
|
|
375
|
+
// not a rollback of money that already moved.
|
|
376
|
+
if (!settled) this._spendRelease(reservation);
|
|
327
377
|
throw e;
|
|
328
378
|
}
|
|
329
379
|
}
|
|
@@ -334,19 +384,21 @@ export class Agent402 {
|
|
|
334
384
|
const host = hostOf(this.baseUrl);
|
|
335
385
|
const usd = parseUsd(tool.price);
|
|
336
386
|
const reservation = this._spendReserve(host, usd, slug);
|
|
387
|
+
let settled = false;
|
|
337
388
|
try {
|
|
338
389
|
const r = await send({ Authorization: `Bearer ${this.creditsKey}` });
|
|
339
390
|
if (r.status === 402) {
|
|
340
391
|
const body = await r.json().catch(() => ({}));
|
|
341
392
|
throw new Error(`call "${slug}" refused by credits: ${body.error || "payment required"}${body.balanceUsd != null ? ` (balance $${body.balanceUsd})` : ""} - top up at ${body.topup || `${this.baseUrl}/credits`}`);
|
|
342
393
|
}
|
|
343
|
-
if (!r.ok) throw new Error(
|
|
394
|
+
if (!r.ok) throw new Error(await this._failureDetail(slug, r));
|
|
344
395
|
this._spendSettle(reservation);
|
|
345
|
-
|
|
346
|
-
|
|
396
|
+
settled = true;
|
|
397
|
+
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
398
|
+
} catch (e) { if (!settled) this._spendRelease(reservation); throw e; }
|
|
347
399
|
}
|
|
348
400
|
const r = await send(); // no wallet - succeeds only on a FREE_MODE instance
|
|
349
|
-
if (r.ok) return this.
|
|
401
|
+
if (r.ok) return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
|
|
350
402
|
throw new Error(`call "${slug}" failed: HTTP ${r.status} - wallet-only tool; construct with { fetch: payFetch } (an @x402/fetch-wrapped fetch) or { creditsKey } (prepaid card credits from ${this.baseUrl}/credits)`);
|
|
351
403
|
}
|
|
352
404
|
|
|
@@ -358,8 +410,31 @@ export class Agent402 {
|
|
|
358
410
|
const chal = await this._powChallenge(slug);
|
|
359
411
|
r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
|
|
360
412
|
}
|
|
361
|
-
if (!r.ok) throw new Error(
|
|
362
|
-
return this.
|
|
413
|
+
if (!r.ok) throw new Error(await this._failureDetail(slug, r, " after proof-of-work"));
|
|
414
|
+
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async _assertOutput(slug, body, validator, { paid = false, cacheHit = false } = {}) {
|
|
418
|
+
if (!validator) return body;
|
|
419
|
+
try {
|
|
420
|
+
// BOUNDED. The validator is caller-owned code we await inside call(), so
|
|
421
|
+
// one that never settles hangs the buyer's call forever - and on a paid
|
|
422
|
+
// route the money has already moved by the time it runs, which makes an
|
|
423
|
+
// indefinite hang the worst possible place to have one. A timeout is a
|
|
424
|
+
// REJECTION, not a pass: an unfinished contract has not been satisfied.
|
|
425
|
+
const result = await withValidatorTimeout(validator.validate(body), validator.timeoutMs ?? OUTPUT_VALIDATOR_TIMEOUT_MS, validator.id);
|
|
426
|
+
if (result === false) throw new Error("validator returned false");
|
|
427
|
+
return body;
|
|
428
|
+
} catch (cause) {
|
|
429
|
+
throw new OutputValidationError(`output for "${slug}" failed buyer validator "${validator.id}"${paid ? "; this call WAS paid" : ""}`,
|
|
430
|
+
{ slug, contractId: validator.id, paid, cacheHit, cause });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async _deliverResponse(slug, response, cacheKey, cache, validator, { maxResponseBytes, paid } = {}) {
|
|
435
|
+
const body = await this._readJson(response, { maxBytes: maxResponseBytes, slug, paid });
|
|
436
|
+
await this._assertOutput(slug, body, validator, { paid });
|
|
437
|
+
return this._store(cacheKey, body, cache);
|
|
363
438
|
}
|
|
364
439
|
|
|
365
440
|
async _powChallenge(slug) {
|
|
@@ -401,6 +476,22 @@ export class Agent402 {
|
|
|
401
476
|
}
|
|
402
477
|
}
|
|
403
478
|
|
|
479
|
+
/** The message for a failed call CARRIES THE SELLER'S OWN DETAIL. Agent402's
|
|
480
|
+
* 4xx bodies are self-explaining ({error, tool, expected}); a message that
|
|
481
|
+
* said only "HTTP 400" threw that away, and an agent reading it could not
|
|
482
|
+
* correct its input (elizaOS registry review, 2026-09-14). Bounded, and a
|
|
483
|
+
* body that is not JSON contributes nothing but its status. */
|
|
484
|
+
async _failureDetail(slug, r, when = "") {
|
|
485
|
+
let detail = "";
|
|
486
|
+
try {
|
|
487
|
+
const body = await r.json();
|
|
488
|
+
const msg = body?.error ?? body?.message ?? body?.detail;
|
|
489
|
+
if (msg != null) detail = ` - ${typeof msg === "string" ? msg : JSON.stringify(msg)}`;
|
|
490
|
+
if (body?.expected != null) detail += ` (expected: ${JSON.stringify(body.expected)})`;
|
|
491
|
+
} catch { /* not JSON: the status is the whole story */ }
|
|
492
|
+
return `call "${slug}" failed${when}: HTTP ${r.status}${detail.slice(0, FAILURE_DETAIL_MAX_CHARS)}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
404
495
|
/** True if any spending ceiling is configured (worth preflighting the 402). */
|
|
405
496
|
_spendCapsConfigured() {
|
|
406
497
|
const s = this._spend;
|
|
@@ -458,6 +549,16 @@ export class ResponseTooLargeError extends Error {
|
|
|
458
549
|
}
|
|
459
550
|
}
|
|
460
551
|
|
|
552
|
+
/** A buyer-owned validator rejected the delivered body. `paid` records whether
|
|
553
|
+
* this invocation had already settled before validation ran. */
|
|
554
|
+
export class OutputValidationError extends Error {
|
|
555
|
+
constructor(message, { slug, contractId, paid, cacheHit, cause } = {}) {
|
|
556
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
557
|
+
this.name = "OutputValidationError";
|
|
558
|
+
Object.assign(this, { slug, contractId, paid: Boolean(paid), cacheHit: Boolean(cacheHit) });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
461
562
|
export class SpendingLimitError extends Error {
|
|
462
563
|
constructor(message, details = {}) {
|
|
463
564
|
super(message);
|
|
@@ -493,6 +594,56 @@ function parseUsd(price) {
|
|
|
493
594
|
}
|
|
494
595
|
function hostOf(url) { try { return new URL(url).host; } catch { return String(url); } }
|
|
495
596
|
|
|
597
|
+
const OUTPUT_VALIDATOR_ID_MAX_CHARS = 256;
|
|
598
|
+
// How long a buyer-owned validator may take before delivery is refused.
|
|
599
|
+
const OUTPUT_VALIDATOR_TIMEOUT_MS = 5_000;
|
|
600
|
+
|
|
601
|
+
function normalizeOutputValidator(value, where) {
|
|
602
|
+
if (value == null) return null;
|
|
603
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
604
|
+
throw new TypeError(`${where} outputValidator must be null or { id, validate }`);
|
|
605
|
+
}
|
|
606
|
+
const id = value.id;
|
|
607
|
+
if (typeof id !== "string" || id.trim() !== id || id.length < 1 || id.length > OUTPUT_VALIDATOR_ID_MAX_CHARS) {
|
|
608
|
+
throw new TypeError(`${where} outputValidator.id must be a nonempty trimmed string of at most ${OUTPUT_VALIDATOR_ID_MAX_CHARS} characters`);
|
|
609
|
+
}
|
|
610
|
+
if (typeof value.validate !== "function") {
|
|
611
|
+
throw new TypeError(`${where} outputValidator.validate must be a function`);
|
|
612
|
+
}
|
|
613
|
+
// Opt-in per-validator override; anything unusable falls back to the default
|
|
614
|
+
// rather than silently disabling the bound.
|
|
615
|
+
const t = Number(value.timeoutMs);
|
|
616
|
+
const timeoutMs = Number.isFinite(t) && t > 0 ? t : OUTPUT_VALIDATOR_TIMEOUT_MS;
|
|
617
|
+
return Object.freeze({ id, validate: value.validate, timeoutMs });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Reject if the caller's validator has not settled in `ms`. The timer is
|
|
621
|
+
* cleared on both paths so a fast validator leaves nothing pending (an
|
|
622
|
+
* un-cleared timer would hold the event loop open on a short-lived script). */
|
|
623
|
+
function withValidatorTimeout(promise, ms, id) {
|
|
624
|
+
let timer;
|
|
625
|
+
return Promise.race([
|
|
626
|
+
Promise.resolve(promise),
|
|
627
|
+
new Promise((_, reject) => {
|
|
628
|
+
// NOT unref'd: an unref'd timer lets a short-lived script exit before the
|
|
629
|
+
// rejection fires, so the buyer gets a silent process exit instead of an
|
|
630
|
+
// OutputValidationError - which is the failure this bound exists to
|
|
631
|
+
// prevent. clearTimeout in finally() is what stops it lingering.
|
|
632
|
+
timer = setTimeout(() => reject(new Error(`validator ${JSON.stringify(id)} did not settle within ${ms}ms`)), ms);
|
|
633
|
+
}),
|
|
634
|
+
]).finally(() => clearTimeout(timer));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function resultCacheKey(slug, params, validator) {
|
|
638
|
+
const requestKey = `${slug}:${JSON.stringify(params)}`;
|
|
639
|
+
if (!validator) return requestKey;
|
|
640
|
+
// The caller supplies a stable semantic identity because function source is
|
|
641
|
+
// neither stable nor meaningful. Hashing keeps arbitrary labels out of map
|
|
642
|
+
// keys and avoids an unbounded readable cache-key surface.
|
|
643
|
+
const digest = createHash("sha256").update(validator.id, "utf8").digest("hex");
|
|
644
|
+
return `${requestKey}#output-validator/v1/${digest}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
496
647
|
/**
|
|
497
648
|
* Restrict + order which chains an @x402 client will pay on (duck-typed - any
|
|
498
649
|
* client version with createPaymentPayload works, zero new dependencies).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-client",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Agent402 buyer SDK: resolve a task to a tool across 500+ pay-per-call endpoints and call it with payment handled - free via proof-of-work, or over x402 or MPP (a stock mppx fetch works) with a wallet you keep; the buy side of Agentic Finance. Caching, idempotent retries (Idempotency-Key), non-custodial, zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|