agent402-client 0.7.0 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +59 -6
  2. package/index.js +142 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,7 +23,7 @@ const a = new Agent402(); // → https://agent402.tools
23
23
 
24
24
  // Don't know the slug? Resolve a task in one call.
25
25
  const matches = await a.find("extract the article from a url");
26
- // → [{ slug: "extract", route, price, inputSchema, example, … }]
26
+ // → [{ slug: "extract", route, price, inputSchema, example, ... }]
27
27
 
28
28
  // Call it - proof-of-work is solved automatically for free tools.
29
29
  const out = await a.call("hash", { text: "hello world", algo: "sha256" });
@@ -101,7 +101,7 @@ same id is a new payment).
101
101
  ## Workflows (skill packs)
102
102
 
103
103
  For jobs that no single tool covers - e.g. "audit a domain", "build a stock
104
- brief" - Agent402 ships curated multi-tool **skill packs**: 5–7 catalog tools
104
+ brief" - Agent402 ships curated multi-tool **skill packs**: 5-7 catalog tools
105
105
  composed into a Claude-ready task template. Discover them the same way you'd
106
106
  discover a tool:
107
107
 
@@ -114,6 +114,38 @@ const { messages } = await a.getWorkflowPrompt("security-audit", { domain: "stri
114
114
  // → feed messages straight to any LLM
115
115
  ```
116
116
 
117
+ ## Cross-seller routing (neutral Smart Order Router)
118
+
119
+ `find()` searches **this host's catalog only**. To rank tools across eligible
120
+ routable seller rows in the host's **current index** (not complete ecosystem
121
+ coverage), use `route()` - free, read-only, no wallet:
122
+
123
+ ```js
124
+ const task = "screenshot webpage";
125
+ const { query, include, results } = await a.route(task, {
126
+ k: 5,
127
+ include: "external", // all | external | local
128
+ network: "robinhood", // optional chain filter (short name or CAIP-2)
129
+ });
130
+ // results[]: opaque server rows - { seller, slug, url, priceUsd, executeVia?, ... }
131
+
132
+ // executeVia quotes which route-execute* tier fits a row's underlying price.
133
+ // It does NOT bind that row: the tier re-resolves an eligible match under its
134
+ // cap at execution time (url, route, seller, and identity can differ).
135
+ const hint = results[0]?.executeVia;
136
+ if (hint?.tool) {
137
+ const { result, receipt } = await a.call(hint.tool, {
138
+ task: query,
139
+ include,
140
+ params: { url: "https://example.com" }, // inputs the chosen operation needs
141
+ }, { cache: false }); // routed externals can be dynamic; client cache is on by default
142
+ }
143
+ ```
144
+
145
+ `executeVia` is server-reported metadata only. The SDK passes discovery rows
146
+ through unchanged, does not validate seller output, and does not guarantee
147
+ which seller or tool the execution tier will pick.
148
+
117
149
  ## Discover the live x402 economy
118
150
 
119
151
  Want to see who's actually getting paid on x402 right now - not just what tools
@@ -133,16 +165,37 @@ await a.topSellers({ sort: "calls", include: "all" });
133
165
 
134
166
  | Method | What |
135
167
  |---|---|
136
- | `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd? })` | `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) |
137
- | `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools (route, price, schema, example) |
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 |
169
+ | `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools on **this host** (route, price, schema, example) |
170
+ | `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`) |
138
171
  | `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
139
172
  | `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
140
173
  | `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) |
141
- | `await a.call(slug, params, { idempotencyKey?, cache? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
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 |
142
175
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
143
176
  | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
144
177
  | `a.clearCache()` | Drop the in-memory result cache |
145
178
 
179
+ ## Response size ceiling
180
+
181
+ You are calling strangers and paying them, and the seller chooses the response.
182
+ By the time `r.json()` resolves, a multi-gigabyte body is already in your
183
+ agent's memory, so this is one of the few checks that cannot be done after the
184
+ fact in your own code.
185
+
186
+ Every call is capped at 32MB by default. The declared `content-length` is
187
+ refused before a byte is read, and the stream is counted as it arrives, because
188
+ `content-length` is the seller's claim about their own body.
189
+
190
+ ```js
191
+ const a = new Agent402({ maxResponseBytes: 1_000_000 }); // 1MB everywhere
192
+ await a.call("hash", { text: "x" }, { maxResponseBytes: null }); // or per call
193
+ ```
194
+
195
+ An oversized body throws `ResponseTooLargeError` carrying `size`, `cap`,
196
+ `source` and `paid`. Check `paid`: on a wallet-only tool the money moved before
197
+ the body arrived, so a refused response is still a spend you made.
198
+
146
199
  ## Spending caps (never overpay)
147
200
 
148
201
  By default the client pays whatever a tool costs. Set optional hard ceilings and a
@@ -160,7 +213,7 @@ const a = new Agent402({
160
213
  });
161
214
 
162
215
  try {
163
- await a.call("some-expensive-tool", { … });
216
+ await a.call("some-expensive-tool", { ... });
164
217
  } catch (e) {
165
218
  if (e instanceof SpendingLimitError) console.log(e.limit, e.priceUsd, e.cap);
166
219
  }
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // agent402-client — a tiny buyer-side client for agent402.tools (or any Agent402
1
+ // agent402-client - a tiny buyer-side client for agent402.tools (or any Agent402
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),
@@ -15,11 +15,16 @@
15
15
  import { createHash } from "node:crypto";
16
16
 
17
17
  // Keep in lockstep with package.json. Every request the SDK issues carries
18
- // `User-Agent: agent402-client/<version>` — a standard header, no extra
19
- // network calls — so a seller can attribute traffic (and settled payments)
18
+ // `User-Agent: agent402-client/<version>` - a standard header, no extra
19
+ // network calls - so a seller can attribute traffic (and settled payments)
20
20
  // to this SDK. Product token only; nothing about the caller rides along.
21
21
  const VERSION = "0.7.0";
22
22
  const USER_AGENT = `agent402-client/${VERSION}`;
23
+ // 32MB: about a hundred times any realistic response from this catalog (the
24
+ // largest is a base64 image at a few MB), so it cannot break a legitimate
25
+ // caller, while still stopping the class it exists for. A ceiling that is off
26
+ // by default protects nobody, so this one is on; pass null to disable it.
27
+ const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
23
28
 
24
29
  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; };
25
30
 
@@ -35,11 +40,14 @@ export class Agent402 {
35
40
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
36
41
  * @param {string} [opts.creditsKey] a prepaid card-credits key (a402_...) from agent402.tools/credits -
37
42
  * pays wallet-only tools by card when no payFetch is given
43
+ * @param {number|null} [opts.maxResponseBytes=33554432]
44
+ * Hard ceiling on a response body, enforced BEFORE it is parsed. null disables it.
38
45
  */
39
46
  constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
40
- maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null } = {}) {
47
+ maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
48
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES } = {}) {
41
49
  this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
42
- if (typeof fetchImpl !== "function") throw new Error("No fetch available — pass { fetchImpl } on Node < 18");
50
+ if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
43
51
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
44
52
  this.payFetch = payFetch || null;
45
53
  // Wrap the plain fetch so every request identifies the SDK (see USER_AGENT
@@ -50,15 +58,74 @@ export class Agent402 {
50
58
  // Spending policy (defends the x402 "wallet drain via uncapped spending"
51
59
  // failure mode): optional hard ceilings enforced BEFORE any payment is
52
60
  // signed. A malicious or misconfigured 402 that quotes an inflated price is
53
- // refused instead of paid. null = no limit (default — behavior unchanged).
61
+ // refused instead of paid. null = no limit (default - behavior unchanged).
54
62
  // Amounts commit to the rolling window only on a settled paid call, so a
55
63
  // failed/blocked call never counts against the budget.
56
64
  this._spend = {
57
65
  maxPerCall: numOrNull(maxPerCallUsd),
58
66
  daily: numOrNull(dailyLimitUsd),
59
67
  perHost: numOrNull(maxPerHostUsd),
60
- log: [], // [{ ts, host, usd }] — settled paid calls in the last 24h
68
+ log: [], // [{ ts, host, usd }] - settled paid calls in the last 24h
61
69
  };
70
+ // A response-size ceiling, enforced before parsing. See _readJson.
71
+ this.maxResponseBytes = maxResponseBytes === null ? null : (numOrNull(maxResponseBytes) ?? DEFAULT_MAX_RESPONSE_BYTES);
72
+ }
73
+
74
+ /**
75
+ * Read a JSON body with a hard byte ceiling.
76
+ *
77
+ * WHY THIS LIVES IN THE SDK. Almost everything a buyer might want to check
78
+ * about a response, they can check themselves in their own code once they
79
+ * hold it. Not this one: by the time `r.json()` has resolved, a hostile or
80
+ * broken seller's multi-gigabyte body is already in the agent's memory. The
81
+ * SDK owns the fetch, so it is the only place the check can happen in time.
82
+ *
83
+ * That matters here more than in an ordinary HTTP client, because this SDK
84
+ * calls STRANGERS and pays them. The seller chooses the response.
85
+ *
86
+ * Two gates: the declared content-length is refused before a byte is read,
87
+ * and the actual stream is counted as it arrives, because content-length is
88
+ * the seller's claim about their own body and a missing or lying header must
89
+ * not be the thing standing between an agent and an OOM.
90
+ */
91
+ async _readJson(r, { maxBytes, slug, paid = false } = {}) {
92
+ const cap = maxBytes === undefined ? this.maxResponseBytes : (maxBytes === null ? null : numOrNull(maxBytes));
93
+ if (cap == null) return r.json();
94
+
95
+ const declared = Number(r.headers?.get?.("content-length"));
96
+ if (Number.isFinite(declared) && declared > cap) {
97
+ throw new ResponseTooLargeError(declared, cap, { slug, paid, source: "content-length" });
98
+ }
99
+ // A response object we cannot measure at all - no stream and no text() -
100
+ // is a caller-supplied stub or an exotic runtime, never a hostile seller
101
+ // over a real fetch (a real Response has both). Degrade to an unbounded
102
+ // read rather than turning "I could not measure this" into a failure of a
103
+ // call the caller may have already paid for.
104
+ if ((!r.body || typeof r.body.getReader !== "function") && typeof r.text !== "function") return r.json();
105
+ // No readable stream but text() is available: bound the PARSE, even though
106
+ // the bytes have already arrived.
107
+ if (!r.body || typeof r.body.getReader !== "function") {
108
+ const text = await r.text();
109
+ const size = Buffer.byteLength(text, "utf8");
110
+ if (size > cap) throw new ResponseTooLargeError(size, cap, { slug, paid, source: "body" });
111
+ return JSON.parse(text);
112
+ }
113
+ const reader = r.body.getReader();
114
+ const chunks = [];
115
+ let received = 0;
116
+ for (;;) {
117
+ const { done, value } = await reader.read();
118
+ if (done) break;
119
+ received += value.length;
120
+ if (received > cap) {
121
+ // Stop pulling immediately. Reading the rest to "see how big it is" is
122
+ // the failure this exists to prevent.
123
+ try { await reader.cancel(); } catch { /* already gone */ }
124
+ throw new ResponseTooLargeError(received, cap, { slug, paid, source: "stream" });
125
+ }
126
+ chunks.push(value);
127
+ }
128
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
62
129
  }
63
130
 
64
131
  async _loadCatalog() {
@@ -79,9 +146,39 @@ export class Agent402 {
79
146
  return (await r.json()).results || [];
80
147
  }
81
148
 
149
+ /**
150
+ * Cross-seller Smart Order Router - rank tools across eligible/routable seller
151
+ * rows in the host's current index (local catalog plus crawled x402/MPP sellers
152
+ * the router considers healthy enough to route). Free; no payment, no wallet, no
153
+ * proof-of-work. Coverage is whatever the host's index holds at call time, not
154
+ * every seller on the internet.
155
+ *
156
+ * Returns the server JSON as-is (reporting only - no validation or authorization).
157
+ * Each result may carry server-reported `executeVia` naming a `route-execute*`
158
+ * tier whose underlying cap covers that row's price. `call(executeVia.tool, ...)`
159
+ * re-resolves an eligible match under that tier at execution time; it does not
160
+ * bind the discovery row's url, route, seller, or identity.
161
+ *
162
+ * @param {string} task
163
+ * @param {object} [opts]
164
+ * @param {number} [opts.k=5] max results (1-25, server-bounded)
165
+ * @param {"all"|"external"|"local"} [opts.include="all"]
166
+ * @param {string} [opts.network] chain filter (e.g. "robinhood", "eip155:4663")
167
+ * @returns {Promise<{query:string, include:string, count:number, sellers:number, results:Array<object>}>}
168
+ */
169
+ async route(task, { k = 5, include = "all", network } = {}) {
170
+ const top = Math.min(Math.max(parseInt(k, 10) || 5, 1), 25);
171
+ const includeParam = include === "external" || include === "local" ? include : "all";
172
+ const qs = new URLSearchParams({ q: String(task ?? ""), k: String(top), include: includeParam });
173
+ if (network) qs.set("network", String(network));
174
+ const r = await this.f(`${this.baseUrl}/api/route?${qs}`);
175
+ if (!r.ok) throw new Error(`route failed: HTTP ${r.status}`);
176
+ return r.json();
177
+ }
178
+
82
179
  /**
83
180
  * Resolve a task to matching multi-tool workflow templates (skill packs).
84
- * Each pack composes 5–7 catalog tools into a Claude-ready task template
181
+ * Each pack composes 5-7 catalog tools into a Claude-ready task template
85
182
  * for jobs that no single tool covers (e.g. audit a domain). Returns
86
183
  * `[{slug, title, tagline, toolSlugs, score, url, promptName}]` (possibly
87
184
  * empty when the lexical signal is weak). Use `getWorkflowPrompt(slug, args)`
@@ -95,7 +192,7 @@ export class Agent402 {
95
192
 
96
193
  /**
97
194
  * Fetch the rendered prompt messages for a skill pack with arguments
98
- * substituted in. Same output as MCP `prompts/get` — usable directly with
195
+ * substituted in. Same output as MCP `prompts/get` - usable directly with
99
196
  * any LLM. `args` are passed by promptArg name (see /api/skill-packs.json).
100
197
  */
101
198
  async getWorkflowPrompt(slug, args = {}) {
@@ -106,11 +203,11 @@ export class Agent402 {
106
203
  }
107
204
 
108
205
  /**
109
- * Live x402 leaderboard — the sellers earning the most USDC (or serving the
206
+ * Live x402 leaderboard - the sellers earning the most USDC (or serving the
110
207
  * most calls) on Base in the last ~24h, derived from on-chain USDC
111
208
  * transfers. Free; no payment, no wallet, no proof-of-work. Useful when
112
209
  * building agents that want to discover the live x402 economy beyond a
113
- * single service's catalog. Hourly snapshot — safe to call freely.
210
+ * single service's catalog. Hourly snapshot - safe to call freely.
114
211
  *
115
212
  * @param {object} [opts]
116
213
  * @param {number} [opts.limit=10] max rows (1-50)
@@ -142,7 +239,7 @@ export class Agent402 {
142
239
  * Register a wallet address for Base builder code attribution. Idempotent:
143
240
  * the same wallet always returns the same code. No authentication required.
144
241
  *
145
- * @param {string} walletAddress the caller's wallet address (e.g. "0x…")
242
+ * @param {string} walletAddress the caller's wallet address (e.g. "0x...")
146
243
  * @param {object} [opts]
147
244
  * @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch)
148
245
  * @returns {Promise<{builderCode:string, walletAddress:string}>}
@@ -169,10 +266,10 @@ export class Agent402 {
169
266
  * Call a tool by slug; pays automatically (PoW for free tools, x402 for
170
267
  * wallet-only) and returns the parsed JSON result.
171
268
  */
172
- async call(slug, params = {}, { idempotencyKey, cache = true } = {}) {
269
+ async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes } = {}) {
173
270
  const cat = await this._loadCatalog();
174
271
  const tool = cat.get(slug);
175
- if (!tool) throw new Error(`unknown tool "${slug}" — use client.find(task) to discover one`);
272
+ if (!tool) throw new Error(`unknown tool "${slug}" - use client.find(task) to discover one`);
176
273
 
177
274
  const cacheKey = `${slug}:${JSON.stringify(params)}`;
178
275
  if (this._cache && cache && this._cache.has(cacheKey)) return this._cache.get(cacheKey);
@@ -180,7 +277,7 @@ export class Agent402 {
180
277
  const idem = idempotencyKey || `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`;
181
278
  const send = (extraHeaders = {}, useFetch = this.f) => {
182
279
  // UA set here too (not only in the this.f wrapper) so the x402 payFetch
183
- // path — the one that settles real payments — always carries it.
280
+ // path - the one that settles real payments - always carries it.
184
281
  const headers = { "User-Agent": USER_AGENT, "Idempotency-Key": idem, ...extraHeaders };
185
282
  let url = `${this.baseUrl}${tool.path}`;
186
283
  const init = { method: tool.method, headers };
@@ -201,12 +298,12 @@ export class Agent402 {
201
298
  // configured ceiling (per-call / rolling-24h / per-host).
202
299
  const host = hostOf(this.baseUrl);
203
300
  let usd = parseUsd(tool.price);
204
- // The catalog price is seller-ADVERTISED — a hostile server could under-
301
+ // The catalog price is seller-ADVERTISED - a hostile server could under-
205
302
  // state it and then quote more in the 402. When a cap is set, preflight
206
303
  // the 402 to learn the price the wallet will actually be asked to sign and
207
304
  // check the cap against the larger of the two. Fail-open: if the 402 can't
208
305
  // be read (FREE_MODE / non-402 / unparseable), fall back to the advertised
209
- // price — never block a legitimate payment on a parse miss.
306
+ // price - never block a legitimate payment on a parse miss.
210
307
  if (this._spendCapsConfigured()) {
211
308
  try {
212
309
  const pre = await send();
@@ -224,9 +321,9 @@ export class Agent402 {
224
321
  const r = await send({}, this.payFetch);
225
322
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
226
323
  this._spendSettle(reservation); // confirm the reservation as settled spend
227
- return this._store(cacheKey, await r.json(), cache);
324
+ return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
228
325
  } catch (e) {
229
- this._spendRelease(reservation); // roll back — nothing settled
326
+ this._spendRelease(reservation); // roll back - nothing settled
230
327
  throw e;
231
328
  }
232
329
  }
@@ -245,16 +342,16 @@ export class Agent402 {
245
342
  }
246
343
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
247
344
  this._spendSettle(reservation);
248
- return this._store(cacheKey, await r.json(), cache);
345
+ return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
249
346
  } catch (e) { this._spendRelease(reservation); throw e; }
250
347
  }
251
- const r = await send(); // no wallet — succeeds only on a FREE_MODE instance
252
- if (r.ok) return this._store(cacheKey, await r.json(), cache);
253
- 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)`);
348
+ 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);
350
+ 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)`);
254
351
  }
255
352
 
256
353
  // Free (compute-payable) tool: succeeds plainly on a FREE_MODE instance,
257
- // otherwise pay with a proof-of-work (fetched from /api/pow/challenge — the
354
+ // otherwise pay with a proof-of-work (fetched from /api/pow/challenge - the
258
355
  // Agent402 server signals it via the X-Pow-Challenge header, not the 402 body).
259
356
  let r = await send();
260
357
  if (!r.ok) {
@@ -262,7 +359,7 @@ export class Agent402 {
262
359
  r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
263
360
  }
264
361
  if (!r.ok) throw new Error(`call "${slug}" failed after proof-of-work: HTTP ${r.status}`);
265
- return this._store(cacheKey, await r.json(), cache);
362
+ return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
266
363
  }
267
364
 
268
365
  async _powChallenge(slug) {
@@ -281,7 +378,7 @@ export class Agent402 {
281
378
  if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
282
379
  if (s.maxPerCall != null && usd > s.maxPerCall) {
283
380
  throw new SpendingLimitError(
284
- `refusing to pay $${usd} for "${slug}" — exceeds maxPerCallUsd $${s.maxPerCall}`,
381
+ `refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}`,
285
382
  { limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall });
286
383
  }
287
384
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
@@ -290,7 +387,7 @@ export class Agent402 {
290
387
  const spent = s.log.reduce((a, e) => a + e.usd, 0);
291
388
  if (spent + usd > s.daily) {
292
389
  throw new SpendingLimitError(
293
- `refusing to pay $${usd} for "${slug}" — would bring 24h spend to $${(spent + usd).toFixed(6)}, over dailyLimitUsd $${s.daily}`,
390
+ `refusing to pay $${usd} for "${slug}" - would bring 24h spend to $${(spent + usd).toFixed(6)}, over dailyLimitUsd $${s.daily}`,
294
391
  { limit: "dailyLimitUsd", slug, priceUsd: usd, spent, cap: s.daily });
295
392
  }
296
393
  }
@@ -298,7 +395,7 @@ export class Agent402 {
298
395
  const spentHost = s.log.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0);
299
396
  if (spentHost + usd > s.perHost) {
300
397
  throw new SpendingLimitError(
301
- `refusing to pay $${usd} for "${slug}" — would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}, over maxPerHostUsd $${s.perHost}`,
398
+ `refusing to pay $${usd} for "${slug}" - would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}, over maxPerHostUsd $${s.perHost}`,
302
399
  { limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, cap: s.perHost });
303
400
  }
304
401
  }
@@ -330,7 +427,7 @@ export class Agent402 {
330
427
  if (i >= 0) this._spend.log.splice(i, 1);
331
428
  }
332
429
 
333
- /** Rolling-24h spend summary (settled paid calls only) — for observability. */
430
+ /** Rolling-24h spend summary (settled paid calls only) - for observability. */
334
431
  spendingSummary() {
335
432
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
336
433
  const log = this._spend.log.filter((e) => e.ts >= cutoff && !e.pending);
@@ -347,6 +444,20 @@ export class Agent402 {
347
444
 
348
445
  /** Thrown when a paid call would exceed a configured spending ceiling. The call
349
446
  * is refused BEFORE any payment is signed, so no funds move. */
447
+ /** A response body over the ceiling, refused BEFORE it was parsed.
448
+ *
449
+ * `paid` matters: on a wallet-only tool the money has already moved by the
450
+ * time the body arrives, so a caller must be able to tell "I was charged and
451
+ * the seller sent something unusable" from "nothing happened". Losing that
452
+ * distinction would turn a refused response into a silently forgotten spend. */
453
+ export class ResponseTooLargeError extends Error {
454
+ constructor(size, cap, { slug, paid, source } = {}) {
455
+ super(`response for "${slug}" is ${size} bytes, over the ${cap}-byte ceiling (${source}) - refused before parsing${paid ? "; this call WAS paid" : ""}`);
456
+ this.name = "ResponseTooLargeError";
457
+ Object.assign(this, { size, cap, slug, paid: Boolean(paid), source });
458
+ }
459
+ }
460
+
350
461
  export class SpendingLimitError extends Error {
351
462
  constructor(message, details = {}) {
352
463
  super(message);
@@ -355,7 +466,7 @@ export class SpendingLimitError extends Error {
355
466
  }
356
467
  }
357
468
 
358
- // Parse the USD amount an x402 `402` challenge actually requires — the max across
469
+ // Parse the USD amount an x402 `402` challenge actually requires - the max across
359
470
  // the offered rails. x402 is stablecoin-settled (USDC/USDG), so
360
471
  // atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
361
472
  // challenge, so the caller fails open to the advertised catalog price.
@@ -383,7 +494,7 @@ function parseUsd(price) {
383
494
  function hostOf(url) { try { return new URL(url).host; } catch { return String(url); } }
384
495
 
385
496
  /**
386
- * Restrict + order which chains an @x402 client will pay on (duck-typed — any
497
+ * Restrict + order which chains an @x402 client will pay on (duck-typed - any
387
498
  * client version with createPaymentPayload works, zero new dependencies).
388
499
  * Multi-chain sellers list Base first, so an unmodified client effectively
389
500
  * always settles there; this makes rails like USDG on Robinhood Chain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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",