@rubicon-caliga/agent-sdk 0.1.1 → 0.1.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.
@@ -0,0 +1,205 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { RubiconClient } from "./agent-client.js";
4
+ import type { AgentPaymentEngine } from "./payment-engine.js";
5
+
6
+ const paymentEngine: AgentPaymentEngine = {
7
+ async createWordPayment() {
8
+ return { paymentPayload: { ok: true } };
9
+ },
10
+ };
11
+
12
+ const chunkPaymentEngine: AgentPaymentEngine = {
13
+ async createWordPayment() {
14
+ throw new Error("word fallback should not be used");
15
+ },
16
+ async createChunkPayment(_session, input) {
17
+ return {
18
+ paymentPayload: { amountAtomic: `${input.maxWords}` },
19
+ maxWords: input.maxWords,
20
+ };
21
+ },
22
+ };
23
+
24
+ test("run receipt preserves Gateway settlement receipt fields", async () => {
25
+ const fetcher = (async (input: Parameters<typeof fetch>[0]) => {
26
+ const url = String(input);
27
+ if (url.endsWith("/v1/sessions")) {
28
+ return jsonResponse({
29
+ sessionId: "session_1",
30
+ state: "active",
31
+ article: article(),
32
+ navigation: navigation(),
33
+ pricePerWordAtomic: "1",
34
+ maxArticlePriceAtomic: "10",
35
+ conversationId: "conversation_1",
36
+ wordPaymentAtomic: "1",
37
+ gatewayFeeBps: 0,
38
+ paymentRequired: { scheme: "exact" },
39
+ expiresAt: "2026-06-18T12:00:00.000Z",
40
+ wordsPaid: 0,
41
+ wordsDelivered: 0,
42
+ paidAtomic: "0",
43
+ });
44
+ }
45
+ if (url.endsWith("/v1/sessions/session_1/payments")) {
46
+ return jsonResponse({
47
+ accepted: true,
48
+ sequence: 0,
49
+ word: "Rubicon",
50
+ priceAtomic: "1",
51
+ wordsPaid: 1,
52
+ wordsDelivered: 1,
53
+ paidAtomic: "1",
54
+ completed: true,
55
+ transactionHashes: [],
56
+ settlementIds: ["settlement_1"],
57
+ payment: {
58
+ paymentId: "payment_1",
59
+ sessionId: "session_1",
60
+ articleId: "article_1",
61
+ sequence: 0,
62
+ meteringUnit: "word",
63
+ amountAtomic: "1",
64
+ currency: "USDC",
65
+ network: "eip155:5042002",
66
+ payTo: "0x3333333333333333333333333333333333333333",
67
+ transactionHashes: [],
68
+ settlementIds: ["settlement_1"],
69
+ buyerWalletAddress: "0x2222222222222222222222222222222222222222",
70
+ settledAt: "2026-06-18T12:00:00.000Z",
71
+ },
72
+ });
73
+ }
74
+ throw new Error(`Unexpected fetch: ${url}`);
75
+ }) as typeof fetch;
76
+
77
+ const client = new RubiconClient({
78
+ baseUrl: "http://rubicon.test",
79
+ paymentEngine,
80
+ fetch: fetcher,
81
+ });
82
+
83
+ const receipt = await client.run({
84
+ articleId: "article_1",
85
+ maxSpendAtomic: "10",
86
+ });
87
+
88
+ assert.deepEqual(receipt.transactionHashes, []);
89
+ assert.deepEqual(receipt.settlementIds, ["settlement_1"]);
90
+ assert.equal(receipt.buyerWalletAddress, "0x2222222222222222222222222222222222222222");
91
+ assert.equal(receipt.sellerPayTo, "0x3333333333333333333333333333333333333333");
92
+ assert.equal(receipt.network, "eip155:5042002");
93
+ });
94
+
95
+ test("run can consume chunk stream while preserving per-word receipt fields", async () => {
96
+ const fetcher = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
97
+ const url = String(input);
98
+ if (url.endsWith("/v1/sessions")) {
99
+ return jsonResponse({
100
+ sessionId: "session_1",
101
+ state: "active",
102
+ article: article(),
103
+ navigation: navigation(),
104
+ pricePerWordAtomic: "1",
105
+ maxArticlePriceAtomic: "10",
106
+ conversationId: "conversation_1",
107
+ wordPaymentAtomic: "1",
108
+ gatewayFeeBps: 0,
109
+ paymentRequired: { accepts: [{ amount: "1" }] },
110
+ expiresAt: "2026-06-18T12:00:00.000Z",
111
+ wordsPaid: 0,
112
+ wordsDelivered: 0,
113
+ paidAtomic: "0",
114
+ });
115
+ }
116
+ if (url.endsWith("/v1/sessions/session_1/stream")) {
117
+ const body = JSON.parse(String(init?.body)) as { maxWords: number };
118
+ assert.equal(body.maxWords, 3);
119
+ return jsonResponse({
120
+ accepted: true,
121
+ words: [
122
+ { sequence: 0, word: "Rubicon", priceAtomic: "1", payment: payment(0, "Rubicon") },
123
+ { sequence: 1, word: "streams", priceAtomic: "1", payment: payment(1, "streams") },
124
+ { sequence: 2, word: "chunks", priceAtomic: "1", payment: payment(2, "chunks") },
125
+ ],
126
+ text: "Rubicon streams chunks",
127
+ wordsPaid: 3,
128
+ wordsDelivered: 3,
129
+ paidAtomic: "3",
130
+ completed: true,
131
+ settlementIds: ["settlement_chunk"],
132
+ });
133
+ }
134
+ throw new Error(`Unexpected fetch: ${url}`);
135
+ }) as typeof fetch;
136
+
137
+ const client = new RubiconClient({
138
+ baseUrl: "http://rubicon.test",
139
+ paymentEngine: chunkPaymentEngine,
140
+ fetch: fetcher,
141
+ });
142
+
143
+ const receipt = await client.run({
144
+ articleId: "article_1",
145
+ maxSpendAtomic: "10",
146
+ chunkWords: 3,
147
+ });
148
+
149
+ assert.equal(receipt.text, "Rubicon streams chunks");
150
+ assert.equal(receipt.wordsRead, 3);
151
+ assert.equal(receipt.amountPaidAtomic, "3");
152
+ assert.equal(receipt.payments.length, 3);
153
+ assert.deepEqual(receipt.settlementIds, ["settlement_chunk"]);
154
+ });
155
+
156
+ function jsonResponse(body: unknown): Response {
157
+ return new Response(JSON.stringify(body), {
158
+ status: 200,
159
+ headers: { "content-type": "application/json" },
160
+ });
161
+ }
162
+
163
+ function article() {
164
+ return {
165
+ articleId: "article_1",
166
+ creatorId: "creator_1",
167
+ creatorUsername: "creator",
168
+ title: "Title",
169
+ author: "Author",
170
+ state: "published",
171
+ totalWords: 1,
172
+ pricePerWordAtomic: "1",
173
+ maxArticlePriceAtomic: "1",
174
+ sections: [],
175
+ };
176
+ }
177
+
178
+ function navigation() {
179
+ return {
180
+ articleId: "article_1",
181
+ sections: [],
182
+ sellerAgent: {
183
+ recommendedSectionId: "intro",
184
+ alternativeSectionIds: [],
185
+ rationale: "",
186
+ safeHints: [],
187
+ withheld: [],
188
+ },
189
+ stopConditions: [],
190
+ };
191
+ }
192
+
193
+ function payment(sequence: number, word: string) {
194
+ return {
195
+ paymentId: `payment_${sequence}`,
196
+ sessionId: "session_1",
197
+ articleId: "article_1",
198
+ sequence,
199
+ meteringUnit: "word",
200
+ amountAtomic: "1",
201
+ currency: "USDC",
202
+ settledAt: "2026-06-18T12:00:00.000Z",
203
+ word,
204
+ };
205
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  StartConversationResponse,
8
8
  StartSessionRequest,
9
9
  StartSessionResponse,
10
+ StreamChunkResponse,
10
11
  StreamPaymentRequest,
11
12
  StreamPaymentResponse,
12
13
  WordPaymentReceipt,
@@ -63,6 +64,14 @@ export type RubiconReadEvent =
63
64
  payment?: WordPaymentReceipt;
64
65
  text: string;
65
66
  }
67
+ | {
68
+ type: "article.chunk";
69
+ words: Array<{ sequence: number; word: string; priceAtomic: `${bigint}`; payment?: WordPaymentReceipt }>;
70
+ text: string;
71
+ wordsRead: number;
72
+ amountPaidAtomic: `${bigint}`;
73
+ completed: boolean;
74
+ }
66
75
  | { type: "article.usage"; wordsPaid: number; wordsDelivered: number; paidAtomic: `${bigint}` }
67
76
  | { type: "article.completed"; receipt: ReadReceipt }
68
77
  | { type: "article.error"; message: string };
@@ -76,6 +85,8 @@ export interface ReadOptions {
76
85
  maxSpendAtomic?: `${bigint}`;
77
86
  budget?: Budget;
78
87
  maxWords?: number;
88
+ /** Number of words to authorize and deliver per gateway round trip when supported. */
89
+ chunkWords?: number;
79
90
  /** Return true to stop reading once enough information has been collected. */
80
91
  stopWhen?: (state: {
81
92
  text: string;
@@ -92,8 +103,8 @@ export interface RunOptions extends ReadOptions {
92
103
 
93
104
  /**
94
105
  * High-level buyer-agent client for Rubicon. `read()` runs the entire
95
- * pay -> word -> usage loop one word at a time until a stop condition is met,
96
- * so application developers never send a payment for every word themselves.
106
+ * authorize -> word -> usage loop until a stop condition is met, so application
107
+ * developers never drive a payment flow for every word themselves.
97
108
  */
98
109
  export class RubiconClient {
99
110
  private readonly fetcher: typeof fetch;
@@ -170,6 +181,18 @@ export class RubiconClient {
170
181
  return response.json() as Promise<StreamPaymentResponse>;
171
182
  }
172
183
 
184
+ async streamChunk(sessionId: string, payment: StreamPaymentRequest): Promise<StreamChunkResponse> {
185
+ const response = await this.fetcher(`${this.baseUrl}/v1/sessions/${sessionId}/stream`, {
186
+ method: "POST",
187
+ headers: this.headers({ "content-type": "application/json" }),
188
+ body: JSON.stringify(payment),
189
+ });
190
+ if (!response.ok) {
191
+ throw new Error(`Chunk stream rejected: ${response.status} ${await response.text()}`);
192
+ }
193
+ return response.json() as Promise<StreamChunkResponse>;
194
+ }
195
+
173
196
  async abort(sessionId: string, reason = "agent_cancelled"): Promise<void> {
174
197
  await this.fetcher(`${this.baseUrl}/v1/sessions/${sessionId}/abort`, {
175
198
  method: "POST",
@@ -222,8 +245,8 @@ export class RubiconClient {
222
245
  }
223
246
 
224
247
  /**
225
- * Read an article one paid word at a time. Yields seller messages, paid words,
226
- * running usage, and a final completion event carrying the receipt.
248
+ * Read an article with word-level metering. Yields seller messages, paid
249
+ * words, running usage, and a final completion event carrying the receipt.
227
250
  */
228
251
  async *read(options: ReadOptions): AsyncGenerator<RubiconReadEvent, ReadReceipt> {
229
252
  const budget: Budget =
@@ -274,6 +297,8 @@ export class RubiconClient {
274
297
  const transactionHashes: string[] = [];
275
298
  const settlementIds: string[] = [];
276
299
  const payments: WordPaymentReceipt[] = [];
300
+ const chunkWords = normalizeChunkWords(options.chunkWords);
301
+ const useChunks = chunkWords > 1 && typeof this.paymentEngine.createChunkPayment === "function";
277
302
  let stopReason: ReadReceipt["stopReason"] = "article_completed";
278
303
  let completed = false;
279
304
 
@@ -308,6 +333,64 @@ export class RubiconClient {
308
333
  break;
309
334
  }
310
335
 
336
+ if (useChunks) {
337
+ const remainingWords = options.maxWords === undefined ? chunkWords : Math.max(0, options.maxWords - wordsRead);
338
+ const affordableWords = Number((budgetAtomic - amountPaid) / wordPaymentAtomic);
339
+ const maxWords = Math.max(1, Math.min(chunkWords, remainingWords || chunkWords, affordableWords));
340
+ const payment = await this.paymentEngine.createChunkPayment!(session, {
341
+ nextSequence: wordsRead,
342
+ maxWords,
343
+ });
344
+ const idempotencyKey = `${session.sessionId}:${wordsRead}:${maxWords}`;
345
+ let result: StreamChunkResponse;
346
+ try {
347
+ result = await this.streamChunk(session.sessionId, { ...payment, idempotencyKey, maxWords });
348
+ } catch (error) {
349
+ yield { type: "article.error", message: error instanceof Error ? error.message : String(error) };
350
+ stopReason = "aborted";
351
+ break;
352
+ }
353
+ if (result.words.length === 0 && result.completed) {
354
+ completed = true;
355
+ stopReason = "article_completed";
356
+ const receipt = makeReceipt();
357
+ yield { type: "article.completed", receipt };
358
+ return receipt;
359
+ }
360
+ for (const entry of result.words) {
361
+ if (entry.payment) {
362
+ payments.push(entry.payment);
363
+ }
364
+ text = text ? `${text} ${entry.word}` : entry.word;
365
+ }
366
+ wordsRead = result.wordsDelivered;
367
+ amountPaid = BigInt(result.paidAtomic);
368
+ transactionHashes.push(...(result.transactionHashes ?? (result.transactionHash ? [result.transactionHash] : [])));
369
+ settlementIds.push(...(result.settlementIds ?? (result.settlementId ? [result.settlementId] : [])));
370
+ yield {
371
+ type: "article.chunk",
372
+ words: result.words,
373
+ text,
374
+ wordsRead,
375
+ amountPaidAtomic: `${amountPaid}`,
376
+ completed: result.completed,
377
+ };
378
+ yield {
379
+ type: "article.usage",
380
+ wordsPaid: result.wordsPaid,
381
+ wordsDelivered: result.wordsDelivered,
382
+ paidAtomic: result.paidAtomic,
383
+ };
384
+ if (result.completed) {
385
+ completed = true;
386
+ stopReason = "article_completed";
387
+ const receipt = makeReceipt();
388
+ yield { type: "article.completed", receipt };
389
+ return receipt;
390
+ }
391
+ continue;
392
+ }
393
+
311
394
  const payment = await this.paymentEngine.createWordPayment(session);
312
395
  // Idempotency key ties this payment to the specific next word; safe retries.
313
396
  const idempotencyKey = `${session.sessionId}:${wordsRead}`;
@@ -391,3 +474,9 @@ export class RubiconClient {
391
474
 
392
475
  /** Backwards-compatible alias. */
393
476
  export const AgentClient = RubiconClient;
477
+
478
+ function normalizeChunkWords(chunkWords: number | undefined): number {
479
+ if (chunkWords === undefined) return 1;
480
+ if (!Number.isInteger(chunkWords) || chunkWords < 1) return 1;
481
+ return Math.min(chunkWords, 256);
482
+ }
@@ -2,10 +2,8 @@ import type { StartSessionResponse, StreamPaymentRequest } from "@rubicon-caliga
2
2
  import { x402Client } from "@x402/core/client";
3
3
  import { registerBatchScheme } from "@circle-fin/x402-batching/client";
4
4
  import { ExactEvmScheme } from "@x402/evm/exact/client";
5
- import {
6
- initiateDeveloperControlledWalletsClient,
7
- type CircleDeveloperControlledWalletsClient,
8
- } from "@circle-fin/developer-controlled-wallets";
5
+ import * as CircleWallets from "@circle-fin/developer-controlled-wallets";
6
+ import type { CircleDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
9
7
  import type { AgentPaymentEngine } from "./payment-engine.js";
10
8
 
11
9
  export interface CircleAgentWalletEngineOptions {
@@ -13,7 +11,7 @@ export interface CircleAgentWalletEngineOptions {
13
11
  apiKey: string;
14
12
  /** Entity secret registered for the Circle developer account. */
15
13
  entitySecret: string;
16
- /** The Agent Wallet that holds USDC and signs each one-word payment. */
14
+ /** The Agent Wallet that holds USDC and signs Circle / Arc authorizations. */
17
15
  walletId: string;
18
16
  /**
19
17
  * The wallet's on-chain address. Optional — when omitted it is resolved once
@@ -80,7 +78,7 @@ class CircleAgentWalletSigner {
80
78
  const res = await this.client.signTypedData({
81
79
  walletId: this.walletId,
82
80
  data: serializeTypedData(toEip712Payload(typed)),
83
- memo: "Rubicon one-word payment",
81
+ memo: "Rubicon reading authorization",
84
82
  });
85
83
  const signature = res.data?.signature;
86
84
  if (!signature) {
@@ -91,10 +89,10 @@ class CircleAgentWalletSigner {
91
89
  }
92
90
 
93
91
  /**
94
- * Circle Agent Wallet engine. Signs the gateway's one-word x402 terms with a
95
- * custodial Circle Agent Wallet the recommended buyer setup so the agent
96
- * never handles a local signing key. Settlement may be batched by Circle, but
97
- * each signed payload still corresponds to exactly one word.
92
+ * Circle Agent Wallet engine. Signs gateway authorization terms with a
93
+ * custodial Circle Agent Wallet so the buyer agent never handles a local
94
+ * signing key. Current gateways may still call the legacy one-word method as a
95
+ * chunk-compatibility path.
98
96
  */
99
97
  export class CircleAgentWalletEngine implements AgentPaymentEngine {
100
98
  private readonly x402 = new x402Client();
@@ -103,7 +101,7 @@ export class CircleAgentWalletEngine implements AgentPaymentEngine {
103
101
  constructor(options: CircleAgentWalletEngineOptions) {
104
102
  const client =
105
103
  options.client ??
106
- initiateDeveloperControlledWalletsClient({
104
+ CircleWallets.initiateDeveloperControlledWalletsClient({
107
105
  apiKey: options.apiKey,
108
106
  entitySecret: options.entitySecret,
109
107
  ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
@@ -119,7 +117,7 @@ export class CircleAgentWalletEngine implements AgentPaymentEngine {
119
117
 
120
118
  async createWordPayment(session: StartSessionResponse): Promise<StreamPaymentRequest> {
121
119
  if (!session.paymentRequired) {
122
- throw new Error("Session did not include an x402 one-word payment requirement");
120
+ throw new Error("Session did not include a legacy x402 payment requirement");
123
121
  }
124
122
  // Resolve the wallet address up front so the synchronous `address` read
125
123
  // inside createPaymentPayload sees a real value.
@@ -1,6 +1,8 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
+ CircleCliGatewaySigner,
5
+ parseCircleGatewayBackingEOA,
4
6
  parseCircleCliSignature,
5
7
  parseCircleCliWalletAddress,
6
8
  } from "./circle-cli-gateway-payment.js";
@@ -37,6 +39,133 @@ test("parses a sole wallet address from Circle CLI list output", () => {
37
39
  );
38
40
  });
39
41
 
42
+ test("parses backing EOA from Circle Gateway balance output", () => {
43
+ assert.equal(
44
+ parseCircleGatewayBackingEOA(
45
+ JSON.stringify({
46
+ data: {
47
+ backingEOA: "0x2222222222222222222222222222222222222222",
48
+ },
49
+ }),
50
+ ),
51
+ "0x2222222222222222222222222222222222222222",
52
+ );
53
+ });
54
+
55
+ test("separates Circle CLI Agent Wallet address from x402 backing EOA", async () => {
56
+ const calls: string[][] = [];
57
+ const signer = new CircleCliGatewaySigner({
58
+ agentWalletAddress: "0x1111111111111111111111111111111111111111",
59
+ chain: "ARC-TESTNET",
60
+ command: "circle",
61
+ runner: async (_command, args) => {
62
+ calls.push(args);
63
+ assert.deepEqual(args, [
64
+ "gateway",
65
+ "balance",
66
+ "--address",
67
+ "0x1111111111111111111111111111111111111111",
68
+ "--chain",
69
+ "ARC-TESTNET",
70
+ "--output",
71
+ "json",
72
+ ]);
73
+ return JSON.stringify({
74
+ data: {
75
+ backingEOA: "0x2222222222222222222222222222222222222222",
76
+ },
77
+ });
78
+ },
79
+ });
80
+
81
+ await signer.ensureAddress();
82
+
83
+ assert.equal(signer.agentWalletAddress, "0x1111111111111111111111111111111111111111");
84
+ assert.equal(signer.address, "0x2222222222222222222222222222222222222222");
85
+ assert.equal(calls.length, 1);
86
+ });
87
+
88
+ test("discovers sole Agent Wallet and then its Gateway backing EOA", async () => {
89
+ const calls: string[][] = [];
90
+ const signer = new CircleCliGatewaySigner({
91
+ chain: "ARC-TESTNET",
92
+ command: "circle",
93
+ runner: async (_command, args) => {
94
+ calls.push(args);
95
+ if (args[0] === "wallet") {
96
+ return JSON.stringify({
97
+ data: {
98
+ wallets: [{ address: "0x1111111111111111111111111111111111111111" }],
99
+ },
100
+ });
101
+ }
102
+ return JSON.stringify({
103
+ data: {
104
+ backingEOA: "0x2222222222222222222222222222222222222222",
105
+ },
106
+ });
107
+ },
108
+ });
109
+
110
+ await signer.ensureAddress();
111
+
112
+ assert.equal(signer.agentWalletAddress, "0x1111111111111111111111111111111111111111");
113
+ assert.equal(signer.address, "0x2222222222222222222222222222222222222222");
114
+ assert.deepEqual(calls, [
115
+ ["wallet", "list", "--chain", "ARC-TESTNET", "--type", "agent", "--output", "json"],
116
+ [
117
+ "gateway",
118
+ "balance",
119
+ "--address",
120
+ "0x1111111111111111111111111111111111111111",
121
+ "--chain",
122
+ "ARC-TESTNET",
123
+ "--output",
124
+ "json",
125
+ ],
126
+ ]);
127
+ });
128
+
129
+ test("typed data message.from uses backing EOA while CLI signs with Agent Wallet", async () => {
130
+ let signedPayload: Record<string, unknown> | undefined;
131
+ const signer = new CircleCliGatewaySigner({
132
+ agentWalletAddress: "0x1111111111111111111111111111111111111111",
133
+ buyerWalletAddress: "0x2222222222222222222222222222222222222222",
134
+ chain: "ARC-TESTNET",
135
+ command: "circle",
136
+ runner: async (_command, args) => {
137
+ const addressFlag = args.indexOf("--address");
138
+ assert.equal(args[addressFlag + 1], "0x1111111111111111111111111111111111111111");
139
+ signedPayload = JSON.parse(args[3] ?? "{}") as Record<string, unknown>;
140
+ return "0xabc123";
141
+ },
142
+ });
143
+ await signer.ensureAddress();
144
+ assert.equal(signer.address, "0x2222222222222222222222222222222222222222");
145
+
146
+ await signer.signTypedData({
147
+ domain: { name: "USD Coin", version: "2", chainId: 5042002 },
148
+ types: {
149
+ TransferWithAuthorization: [
150
+ { name: "from", type: "address" },
151
+ { name: "to", type: "address" },
152
+ { name: "value", type: "uint256" },
153
+ ],
154
+ },
155
+ primaryType: "TransferWithAuthorization",
156
+ message: {
157
+ from: signer.address,
158
+ to: "0x3333333333333333333333333333333333333333",
159
+ value: 1n,
160
+ },
161
+ });
162
+
163
+ assert.equal(
164
+ (signedPayload?.message as Record<string, unknown>).from,
165
+ "0x2222222222222222222222222222222222222222",
166
+ );
167
+ });
168
+
40
169
  test("requires explicit wallet address when multiple Agent Wallets are present", () => {
41
170
  assert.throws(
42
171
  () =>