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.
Files changed (3) hide show
  1. package/README.md +89 -16
  2. package/index.js +169 -18
  3. 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
- retries reuse an `Idempotency-Key` so a lost response never double-charges.
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 never double-charge
93
-
94
- Every paid call the SDK makes carries an `Idempotency-Key`, so a retry of a
95
- call whose response was lost replays the original result instead of paying
96
- again. If your x402 client attaches the standard `payment-identifier`
97
- extension to its payloads instead, Agent402 honours that id the same way (an
98
- exact retry with the same credential replays; a fresh authorization with the
99
- same id is a new payment).
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, executeVia?, ... }
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 - a blocked or failed
223
- call never consumes budget. Free proof-of-work calls are never counted. Omit a cap
224
- (or leave it `null`) for no limit; with none set, behavior is unchanged.
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 retries reuse an
6
- // Idempotency-Key so a lost response never double-charges.
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.2";
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 = `${slug}:${JSON.stringify(params)}`;
275
- if (this._cache && cache && this._cache.has(cacheKey)) return this._cache.get(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
- const idem = idempotencyKey || `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`;
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(`call "${slug}" failed: HTTP ${r.status}`);
369
+ if (!r.ok) throw new Error(await this._failureDetail(slug, r));
323
370
  this._spendSettle(reservation); // confirm the reservation as settled spend
324
- return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
371
+ settled = true;
372
+ return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
325
373
  } catch (e) {
326
- this._spendRelease(reservation); // roll back - nothing settled
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(`call "${slug}" failed: HTTP ${r.status}`);
394
+ if (!r.ok) throw new Error(await this._failureDetail(slug, r));
344
395
  this._spendSettle(reservation);
345
- return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
346
- } catch (e) { this._spendRelease(reservation); throw e; }
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._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
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(`call "${slug}" failed after proof-of-work: HTTP ${r.status}`);
362
- return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
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.2",
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",