agent402-client 0.7.0 → 0.8.0

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 +22 -2
  2. package/index.js +87 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -133,16 +133,36 @@ await a.topSellers({ sort: "calls", include: "all" });
133
133
 
134
134
  | Method | What |
135
135
  |---|---|
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) |
136
+ | `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 |
137
137
  | `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools (route, price, schema, example) |
138
138
  | `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
139
139
  | `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
140
140
  | `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 |
141
+ | `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
142
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
143
143
  | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
144
144
  | `a.clearCache()` | Drop the in-memory result cache |
145
145
 
146
+ ## Response size ceiling
147
+
148
+ You are calling strangers and paying them, and the seller chooses the response.
149
+ By the time `r.json()` resolves, a multi-gigabyte body is already in your
150
+ agent's memory, so this is one of the few checks that cannot be done after the
151
+ fact in your own code.
152
+
153
+ Every call is capped at 32MB by default. The declared `content-length` is
154
+ refused before a byte is read, and the stream is counted as it arrives, because
155
+ `content-length` is the seller's claim about their own body.
156
+
157
+ ```js
158
+ const a = new Agent402({ maxResponseBytes: 1_000_000 }); // 1MB everywhere
159
+ await a.call("hash", { text: "x" }, { maxResponseBytes: null }); // or per call
160
+ ```
161
+
162
+ An oversized body throws `ResponseTooLargeError` carrying `size`, `cap`,
163
+ `source` and `paid`. Check `paid`: on a wallet-only tool the money moved before
164
+ the body arrived, so a refused response is still a spend you made.
165
+
146
166
  ## Spending caps (never overpay)
147
167
 
148
168
  By default the client pays whatever a tool costs. Set optional hard ceilings and a
package/index.js CHANGED
@@ -20,6 +20,11 @@ import { createHash } from "node:crypto";
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,9 +40,12 @@ 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
50
  if (typeof fetchImpl !== "function") throw new Error("No fetch available — pass { fetchImpl } on Node < 18");
43
51
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
@@ -59,6 +67,65 @@ export class Agent402 {
59
67
  perHost: numOrNull(maxPerHostUsd),
60
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() {
@@ -169,7 +236,7 @@ export class Agent402 {
169
236
  * Call a tool by slug; pays automatically (PoW for free tools, x402 for
170
237
  * wallet-only) and returns the parsed JSON result.
171
238
  */
172
- async call(slug, params = {}, { idempotencyKey, cache = true } = {}) {
239
+ async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes } = {}) {
173
240
  const cat = await this._loadCatalog();
174
241
  const tool = cat.get(slug);
175
242
  if (!tool) throw new Error(`unknown tool "${slug}" — use client.find(task) to discover one`);
@@ -224,7 +291,7 @@ export class Agent402 {
224
291
  const r = await send({}, this.payFetch);
225
292
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
226
293
  this._spendSettle(reservation); // confirm the reservation as settled spend
227
- return this._store(cacheKey, await r.json(), cache);
294
+ return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
228
295
  } catch (e) {
229
296
  this._spendRelease(reservation); // roll back — nothing settled
230
297
  throw e;
@@ -245,11 +312,11 @@ export class Agent402 {
245
312
  }
246
313
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
247
314
  this._spendSettle(reservation);
248
- return this._store(cacheKey, await r.json(), cache);
315
+ return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
249
316
  } catch (e) { this._spendRelease(reservation); throw e; }
250
317
  }
251
318
  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);
319
+ if (r.ok) return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
253
320
  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
321
  }
255
322
 
@@ -262,7 +329,7 @@ export class Agent402 {
262
329
  r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
263
330
  }
264
331
  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);
332
+ return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
266
333
  }
267
334
 
268
335
  async _powChallenge(slug) {
@@ -347,6 +414,20 @@ export class Agent402 {
347
414
 
348
415
  /** Thrown when a paid call would exceed a configured spending ceiling. The call
349
416
  * is refused BEFORE any payment is signed, so no funds move. */
417
+ /** A response body over the ceiling, refused BEFORE it was parsed.
418
+ *
419
+ * `paid` matters: on a wallet-only tool the money has already moved by the
420
+ * time the body arrives, so a caller must be able to tell "I was charged and
421
+ * the seller sent something unusable" from "nothing happened". Losing that
422
+ * distinction would turn a refused response into a silently forgotten spend. */
423
+ export class ResponseTooLargeError extends Error {
424
+ constructor(size, cap, { slug, paid, source } = {}) {
425
+ super(`response for "${slug}" is ${size} bytes, over the ${cap}-byte ceiling (${source}) - refused before parsing${paid ? "; this call WAS paid" : ""}`);
426
+ this.name = "ResponseTooLargeError";
427
+ Object.assign(this, { size, cap, slug, paid: Boolean(paid), source });
428
+ }
429
+ }
430
+
350
431
  export class SpendingLimitError extends Error {
351
432
  constructor(message, details = {}) {
352
433
  super(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",