agent402-client 0.8.2 → 0.8.3

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 +87 -15
  2. package/index.js +149 -15
  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
 
@@ -165,13 +185,13 @@ await a.topSellers({ sort: "calls", include: "all" });
165
185
 
166
186
  | Method | What |
167
187
  |---|---|
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 |
188
+ | `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
189
  | `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools on **this host** (route, price, schema, example) |
170
190
  | `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
191
  | `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
172
192
  | `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
173
193
  | `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 |
194
+ | `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
195
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
176
196
  | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
177
197
  | `a.clearCache()` | Drop the in-memory result cache |
@@ -196,6 +216,56 @@ An oversized body throws `ResponseTooLargeError` carrying `size`, `cap`,
196
216
  `source` and `paid`. Check `paid`: on a wallet-only tool the money moved before
197
217
  the body arrived, so a refused response is still a spend you made.
198
218
 
219
+ ## Buyer-owned output validation
220
+
221
+ A successful payment and HTTP 200 do not prove that the delivered result is
222
+ useful. Supply a stable contract id and a local callback to enforce the exact
223
+ output your agent needs:
224
+
225
+ ```js
226
+ const a = new Agent402({
227
+ fetch: payFetch,
228
+ outputValidator: {
229
+ id: "unemployment-result/v1",
230
+ validate: (body) =>
231
+ typeof body.current === "number" &&
232
+ Array.isArray(body.history) &&
233
+ typeof body.source === "string",
234
+ },
235
+ });
236
+ ```
237
+
238
+ `validate` may return `false` or throw to reject delivery, and may be async.
239
+ Use any local validator you prefer: Ajv, Zod, agent-payment-policy, or ordinary
240
+ application code. The client adds no dependency and never sends the validator
241
+ or its id to the seller.
242
+
243
+ Validation runs after bounded JSON parsing and before cache admission. A
244
+ rejected paid response throws `OutputValidationError` with `paid: true`, stays
245
+ in `spendingSummary()`, and is never cached. Contracted cache entries are
246
+ namespaced by a SHA-256 digest of `id` and revalidated on every hit, so a
247
+ different contract or a caller-mutated cached object cannot bypass the check.
248
+ The caller owns the meaning and stability of `id`; changing validation
249
+ semantics requires a new id. A per-call validator may replace the constructor
250
+ validator, but `null` or omission preserves the constructor policy rather than
251
+ silently weakening it. Use a separate client when a route intentionally has no
252
+ output contract.
253
+
254
+ **Two costs worth knowing.** `validate` is awaited inside `call()`, so it is
255
+ bounded: a validator that has not settled in **5 seconds** rejects delivery with
256
+ `OutputValidationError` rather than hanging the call. On a paid route the money
257
+ has already moved by then, so an indefinite hang would be the worst place to
258
+ have one; a timeout is a rejection, because an unfinished contract is not a
259
+ satisfied one. Override per validator with `timeoutMs`:
260
+
261
+ ```js
262
+ outputValidator: { id: "big-schema/v1", validate: slowCheck, timeoutMs: 30_000 }
263
+ ```
264
+
265
+ And because contracted cache entries are revalidated on **every** hit, an
266
+ expensive validator is paid for on cache hits too, not just on the network call.
267
+ Keep it cheap, or accept that cost knowingly.
268
+
199
269
  ## Spending caps (never overpay)
200
270
 
201
271
  By default the client pays whatever a tool costs. Set optional hard ceilings and a
@@ -219,9 +289,11 @@ try {
219
289
  }
220
290
  ```
221
291
 
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.
292
+ Only **settled** paid calls count against the rolling window. A call refused or
293
+ failed before settlement never consumes budget. A paid HTTP-success response
294
+ that later fails byte, JSON, or output validation remains settled spend. Free
295
+ proof-of-work calls are never counted. Omit a cap (or leave it `null`) for no
296
+ limit; with none set, behavior is unchanged.
225
297
 
226
298
  **What the caps check.** When a cap is set, the client preflights the `402` and
227
299
  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,13 +17,13 @@
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.3";
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
@@ -42,10 +47,13 @@ export class Agent402 {
42
47
  * pays wallet-only tools by card when no payFetch is given
43
48
  * @param {number|null} [opts.maxResponseBytes=33554432]
44
49
  * Hard ceiling on a response body, enforced BEFORE it is parsed. null disables it.
50
+ * @param {{id:string, validate:function}} [opts.outputValidator]
51
+ * Optional buyer-owned result validator. `id` namespaces cache entries;
52
+ * `validate` may return false or throw to reject delivery.
45
53
  */
46
54
  constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
47
55
  maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
48
- maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES } = {}) {
56
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null } = {}) {
49
57
  this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
50
58
  if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
51
59
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
@@ -55,6 +63,14 @@ export class Agent402 {
55
63
  this.f = (url, init = {}) => fetchImpl(url, { ...init, headers: { "User-Agent": USER_AGENT, ...(init.headers || {}) } });
56
64
  this._catalog = null;
57
65
  this._cache = cache ? new Map() : null;
66
+ // Salt for the default Idempotency-Key: one per client instance, so the key
67
+ // for (slug, params) is stable across call() invocations on THIS client
68
+ // (an agent framework retrying a lost response gets the replay) and still
69
+ // distinct between sessions (two processes buying the same thing are two
70
+ // purchases). Issue #1126: the old default mixed Date.now() + Math.random()
71
+ // per invocation, which protected retries inside one call() and nothing
72
+ // the header promised beyond it.
73
+ this._idemSalt = randomBytes(12).toString("hex");
58
74
  // Spending policy (defends the x402 "wallet drain via uncapped spending"
59
75
  // failure mode): optional hard ceilings enforced BEFORE any payment is
60
76
  // signed. A malicious or misconfigured 402 that quotes an inflated price is
@@ -69,6 +85,9 @@ export class Agent402 {
69
85
  };
70
86
  // A response-size ceiling, enforced before parsing. See _readJson.
71
87
  this.maxResponseBytes = maxResponseBytes === null ? null : (numOrNull(maxResponseBytes) ?? DEFAULT_MAX_RESPONSE_BYTES);
88
+ // Buyer-owned delivery policy. The callback stays entirely local and may
89
+ // use Ajv, Zod, agent-payment-policy, or ordinary application code.
90
+ this.outputValidator = normalizeOutputValidator(outputValidator, "constructor");
72
91
  }
73
92
 
74
93
  /**
@@ -266,15 +285,41 @@ export class Agent402 {
266
285
  * Call a tool by slug; pays automatically (PoW for free tools, x402 for
267
286
  * wallet-only) and returns the parsed JSON result.
268
287
  */
269
- async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes } = {}) {
288
+ async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes, outputValidator } = {}) {
289
+ // Resolve and validate the buyer-owned contract before catalog fetch,
290
+ // credential access, payment preflight, or any other network action.
291
+ const validator = outputValidator == null
292
+ ? this.outputValidator
293
+ : normalizeOutputValidator(outputValidator, "call");
270
294
  const cat = await this._loadCatalog();
271
295
  const tool = cat.get(slug);
272
296
  if (!tool) throw new Error(`unknown tool "${slug}" - use client.find(task) to discover one`);
273
297
 
274
- const cacheKey = `${slug}:${JSON.stringify(params)}`;
275
- if (this._cache && cache && this._cache.has(cacheKey)) return this._cache.get(cacheKey);
298
+ const cacheKey = resultCacheKey(slug, params, validator);
299
+ if (this._cache && cache && this._cache.has(cacheKey)) {
300
+ const stored = this._cache.get(cacheKey);
301
+ // Callers receive the stored object by reference and may mutate it.
302
+ // Revalidate contracted hits so a now-invalid object cannot pass merely
303
+ // because it was valid before the caller changed it.
304
+ try {
305
+ await this._assertOutput(slug, stored, validator, { paid: false, cacheHit: true });
306
+ } catch (error) {
307
+ this._cache.delete(cacheKey);
308
+ throw error;
309
+ }
310
+ return stored;
311
+ }
276
312
 
277
- const idem = idempotencyKey || `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`;
313
+ // Default key = stable per (client instance, slug, params, validator) while
314
+ // caching is on: the SDK already treats that tuple as one operation (a
315
+ // second identical call is served from cache without payment), so the
316
+ // idempotency key agrees with the cache key instead of contradicting it.
317
+ // With { cache: false } the caller has said identical calls are distinct
318
+ // purchases, so the key is fresh per invocation, as before. An explicit
319
+ // idempotencyKey always wins.
320
+ const idem = idempotencyKey || (cache
321
+ ? `a402-${createHash("sha256").update(`${this._idemSalt}:${cacheKey}`).digest("hex").slice(0, 24)}`
322
+ : `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`);
278
323
  const send = (extraHeaders = {}, useFetch = this.f) => {
279
324
  // UA set here too (not only in the this.f wrapper) so the x402 payFetch
280
325
  // path - the one that settles real payments - always carries it.
@@ -317,13 +362,17 @@ export class Agent402 {
317
362
  // can't each observe the pre-commit total and collectively blow a rolling
318
363
  // cap; release the reservation if the call doesn't settle.
319
364
  const reservation = this._spendReserve(host, usd, slug);
365
+ let settled = false;
320
366
  try {
321
367
  const r = await send({}, this.payFetch);
322
368
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
323
369
  this._spendSettle(reservation); // confirm the reservation as settled spend
324
- return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
370
+ settled = true;
371
+ return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
325
372
  } catch (e) {
326
- this._spendRelease(reservation); // roll back - nothing settled
373
+ // An invalid or oversized paid HTTP-success body is failed delivery,
374
+ // not a rollback of money that already moved.
375
+ if (!settled) this._spendRelease(reservation);
327
376
  throw e;
328
377
  }
329
378
  }
@@ -334,6 +383,7 @@ export class Agent402 {
334
383
  const host = hostOf(this.baseUrl);
335
384
  const usd = parseUsd(tool.price);
336
385
  const reservation = this._spendReserve(host, usd, slug);
386
+ let settled = false;
337
387
  try {
338
388
  const r = await send({ Authorization: `Bearer ${this.creditsKey}` });
339
389
  if (r.status === 402) {
@@ -342,11 +392,12 @@ export class Agent402 {
342
392
  }
343
393
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
344
394
  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; }
395
+ settled = true;
396
+ return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
397
+ } catch (e) { if (!settled) this._spendRelease(reservation); throw e; }
347
398
  }
348
399
  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);
400
+ if (r.ok) return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
350
401
  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
402
  }
352
403
 
@@ -359,7 +410,30 @@ export class Agent402 {
359
410
  r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
360
411
  }
361
412
  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
+ return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
414
+ }
415
+
416
+ async _assertOutput(slug, body, validator, { paid = false, cacheHit = false } = {}) {
417
+ if (!validator) return body;
418
+ try {
419
+ // BOUNDED. The validator is caller-owned code we await inside call(), so
420
+ // one that never settles hangs the buyer's call forever - and on a paid
421
+ // route the money has already moved by the time it runs, which makes an
422
+ // indefinite hang the worst possible place to have one. A timeout is a
423
+ // REJECTION, not a pass: an unfinished contract has not been satisfied.
424
+ const result = await withValidatorTimeout(validator.validate(body), validator.timeoutMs ?? OUTPUT_VALIDATOR_TIMEOUT_MS, validator.id);
425
+ if (result === false) throw new Error("validator returned false");
426
+ return body;
427
+ } catch (cause) {
428
+ throw new OutputValidationError(`output for "${slug}" failed buyer validator "${validator.id}"${paid ? "; this call WAS paid" : ""}`,
429
+ { slug, contractId: validator.id, paid, cacheHit, cause });
430
+ }
431
+ }
432
+
433
+ async _deliverResponse(slug, response, cacheKey, cache, validator, { maxResponseBytes, paid } = {}) {
434
+ const body = await this._readJson(response, { maxBytes: maxResponseBytes, slug, paid });
435
+ await this._assertOutput(slug, body, validator, { paid });
436
+ return this._store(cacheKey, body, cache);
363
437
  }
364
438
 
365
439
  async _powChallenge(slug) {
@@ -458,6 +532,16 @@ export class ResponseTooLargeError extends Error {
458
532
  }
459
533
  }
460
534
 
535
+ /** A buyer-owned validator rejected the delivered body. `paid` records whether
536
+ * this invocation had already settled before validation ran. */
537
+ export class OutputValidationError extends Error {
538
+ constructor(message, { slug, contractId, paid, cacheHit, cause } = {}) {
539
+ super(message, cause === undefined ? undefined : { cause });
540
+ this.name = "OutputValidationError";
541
+ Object.assign(this, { slug, contractId, paid: Boolean(paid), cacheHit: Boolean(cacheHit) });
542
+ }
543
+ }
544
+
461
545
  export class SpendingLimitError extends Error {
462
546
  constructor(message, details = {}) {
463
547
  super(message);
@@ -493,6 +577,56 @@ function parseUsd(price) {
493
577
  }
494
578
  function hostOf(url) { try { return new URL(url).host; } catch { return String(url); } }
495
579
 
580
+ const OUTPUT_VALIDATOR_ID_MAX_CHARS = 256;
581
+ // How long a buyer-owned validator may take before delivery is refused.
582
+ const OUTPUT_VALIDATOR_TIMEOUT_MS = 5_000;
583
+
584
+ function normalizeOutputValidator(value, where) {
585
+ if (value == null) return null;
586
+ if (typeof value !== "object" || Array.isArray(value)) {
587
+ throw new TypeError(`${where} outputValidator must be null or { id, validate }`);
588
+ }
589
+ const id = value.id;
590
+ if (typeof id !== "string" || id.trim() !== id || id.length < 1 || id.length > OUTPUT_VALIDATOR_ID_MAX_CHARS) {
591
+ throw new TypeError(`${where} outputValidator.id must be a nonempty trimmed string of at most ${OUTPUT_VALIDATOR_ID_MAX_CHARS} characters`);
592
+ }
593
+ if (typeof value.validate !== "function") {
594
+ throw new TypeError(`${where} outputValidator.validate must be a function`);
595
+ }
596
+ // Opt-in per-validator override; anything unusable falls back to the default
597
+ // rather than silently disabling the bound.
598
+ const t = Number(value.timeoutMs);
599
+ const timeoutMs = Number.isFinite(t) && t > 0 ? t : OUTPUT_VALIDATOR_TIMEOUT_MS;
600
+ return Object.freeze({ id, validate: value.validate, timeoutMs });
601
+ }
602
+
603
+ /** Reject if the caller's validator has not settled in `ms`. The timer is
604
+ * cleared on both paths so a fast validator leaves nothing pending (an
605
+ * un-cleared timer would hold the event loop open on a short-lived script). */
606
+ function withValidatorTimeout(promise, ms, id) {
607
+ let timer;
608
+ return Promise.race([
609
+ Promise.resolve(promise),
610
+ new Promise((_, reject) => {
611
+ // NOT unref'd: an unref'd timer lets a short-lived script exit before the
612
+ // rejection fires, so the buyer gets a silent process exit instead of an
613
+ // OutputValidationError - which is the failure this bound exists to
614
+ // prevent. clearTimeout in finally() is what stops it lingering.
615
+ timer = setTimeout(() => reject(new Error(`validator ${JSON.stringify(id)} did not settle within ${ms}ms`)), ms);
616
+ }),
617
+ ]).finally(() => clearTimeout(timer));
618
+ }
619
+
620
+ function resultCacheKey(slug, params, validator) {
621
+ const requestKey = `${slug}:${JSON.stringify(params)}`;
622
+ if (!validator) return requestKey;
623
+ // The caller supplies a stable semantic identity because function source is
624
+ // neither stable nor meaningful. Hashing keeps arbitrary labels out of map
625
+ // keys and avoids an unbounded readable cache-key surface.
626
+ const digest = createHash("sha256").update(validator.id, "utf8").digest("hex");
627
+ return `${requestKey}#output-validator/v1/${digest}`;
628
+ }
629
+
496
630
  /**
497
631
  * Restrict + order which chains an @x402 client will pay on (duck-typed - any
498
632
  * 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.3",
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",