@agent-cards/commerce-sdk 0.5.0 → 0.9.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.
package/dist/client.d.ts CHANGED
@@ -1,14 +1,55 @@
1
+ import { type AgentcardListener, type ListenOptions } from './listen.js';
1
2
  /** The fetch the client calls: Node 18 and later have one built in. */
2
3
  export type FetchLike = (url: string, init: {
3
4
  method: string;
4
5
  headers: Record<string, string>;
5
6
  body?: string;
6
7
  signal?: AbortSignal;
7
- }) => Promise<{
8
+ }) => Promise<FetchLikeResponse>;
9
+ /**
10
+ * The answer a fetch returns, as far as the client reads it. `headers` and `body` are optional: a
11
+ * fetch without them is read whole with `text()`, and a streamed purchase answer still arrives, at its end.
12
+ */
13
+ export interface FetchLikeResponse {
8
14
  status: number;
9
15
  ok: boolean;
10
16
  text(): Promise<string>;
11
- }>;
17
+ headers?: {
18
+ get(name: string): string | null;
19
+ } | null;
20
+ body?: {
21
+ getReader(): {
22
+ read(): Promise<{
23
+ done: boolean;
24
+ value?: Uint8Array;
25
+ }>;
26
+ cancel?(): Promise<void>;
27
+ };
28
+ } | null;
29
+ }
30
+ /**
31
+ * What the purchase turn is doing while it runs: `searching` the stores, `building_cart`, `checking_out`,
32
+ * or `working` on anything else. Agentcard names only the stage, never what the shopper said.
33
+ */
34
+ export type BuyProgressStage = 'searching' | 'building_cart' | 'checking_out' | 'working';
35
+ /** One step of a running purchase turn. */
36
+ export interface BuyProgress {
37
+ stage: BuyProgressStage;
38
+ conversationId: string | null;
39
+ }
40
+ /** The products a running turn's search found, sent the moment the search answers and before the reply is written. */
41
+ export interface BuyCatalogUpdate {
42
+ conversationId: string;
43
+ catalog: BuyCatalog;
44
+ /** When the search ran, as the answer's `catalog.as_of` names it: the same search in the answer has the same value. */
45
+ asOf: string | null;
46
+ }
47
+ /** Called while a purchase turn runs. Passing either one asks Agentcard to stream the turn. */
48
+ export interface BuyStreamHandlers {
49
+ onProgress?: (progress: BuyProgress) => void;
50
+ /** The client waits for what this returns before it hands back the turn's answer. */
51
+ onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
52
+ }
12
53
  export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
13
54
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
14
55
  export declare const BUY_TURN_TIMEOUT_MS = 130000;
@@ -53,10 +94,14 @@ export interface BuyRequest {
53
94
  new_order?: true;
54
95
  /**
55
96
  * The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
56
- * Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
97
+ * Agentcard adds exactly that item, with no new search. Or the store the shopper picked from the last
98
+ * answer's `stores.items`, by its `id`: Agentcard selects exactly that store before the turn runs.
99
+ * Goes with an `ask`, never with `confirm` or `new_order`.
57
100
  */
58
101
  pick?: {
59
102
  product_id: string;
103
+ } | {
104
+ store_id: string;
60
105
  };
61
106
  payment_source?: 'vault';
62
107
  delivery_address?: BuyDeliveryAddress;
@@ -119,11 +164,16 @@ export interface BuyCatalogItem {
119
164
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
120
165
  url: string | null;
121
166
  }
122
- /** The products the last search found. */
167
+ /**
168
+ * The products the last search found. `presented` is true when this answer's reply presents them: the
169
+ * turn searched, or answered a repeated ask from the results it already held. False when they only
170
+ * stand from an earlier answer, and on an older API.
171
+ */
123
172
  export interface BuyCatalog {
124
173
  merchant: string;
125
174
  merchant_name: string;
126
175
  items: BuyCatalogItem[];
176
+ presented: boolean;
127
177
  }
128
178
  /** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
129
179
  export interface BuyPick {
@@ -131,6 +181,30 @@ export interface BuyPick {
131
181
  ref: string | null;
132
182
  in_cart: boolean;
133
183
  }
184
+ /**
185
+ * One store a delivery store search found that the answer's reply names. `id` holds no link and is what
186
+ * `pick.store_id` takes; `store_page_url` is the store's page at the merchant. `eta_minutes` is the
187
+ * merchant's delivery estimate, or null when it showed none.
188
+ */
189
+ export interface BuyStoreChoice {
190
+ id: string;
191
+ name: string;
192
+ store_page_url: string | null;
193
+ eta_minutes: number | null;
194
+ }
195
+ /** The stores an answer's reply presents to choose from, at most three, in the order the reply names them. */
196
+ export interface BuyStores {
197
+ merchant: string;
198
+ merchant_name: string;
199
+ items: BuyStoreChoice[];
200
+ as_of: string | null;
201
+ }
202
+ /** The store a store pick named, and whether Agentcard selected it before the turn ran. */
203
+ export interface BuyStorePick {
204
+ store_id: string;
205
+ name: string | null;
206
+ selected: boolean;
207
+ }
134
208
  export interface BuyPaymentSource {
135
209
  source: string;
136
210
  brand: string | null;
@@ -177,6 +251,10 @@ export interface BuyTurn {
177
251
  store_login_merchant_name: string | null;
178
252
  /** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
179
253
  pick: BuyPick | null;
254
+ /** The stores this answer's reply presents to choose from; null when it presents none, and on an older API. */
255
+ stores: BuyStores | null;
256
+ /** The store this turn's `pick.store_id` named, or an answer such as `the second one` picked; null without one. */
257
+ store_pick: BuyStorePick | null;
180
258
  /** The answer as Agentcard sent it. */
181
259
  raw: Record<string, unknown>;
182
260
  }
@@ -260,15 +338,18 @@ export declare class AgentcardClient {
260
338
  get company(): boolean;
261
339
  /** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
262
340
  get sandbox(): boolean | null;
263
- /** The access token the client sends: the shopper's own, or the company's, exchanged once and renewed a minute before it expires. */
264
- bearer(): Promise<string>;
341
+ /**
342
+ * The access token the client sends: the shopper's own, or the company's, exchanged once and
343
+ * renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
344
+ */
345
+ bearer(refused?: string): Promise<string>;
265
346
  /**
266
347
  * One purchase turn, sent once with a 130-second wait. A turn is a model run
267
348
  * against live stores, so a lost answer is never sent again: read the
268
349
  * conversation instead. Only a 401 is sent again, once, with a fresh token,
269
350
  * because Agentcard refused it before anything ran.
270
351
  */
271
- buy(body: BuyRequest): Promise<BuyTurn>;
352
+ buy(body: BuyRequest, stream?: BuyStreamHandlers): Promise<BuyTurn>;
272
353
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
273
354
  conversation(conversationId: string, who?: Shopper): Promise<BuyConversation>;
274
355
  /**
@@ -279,6 +360,11 @@ export declare class AgentcardClient {
279
360
  mintVaultLink(input: {
280
361
  externalUserId: string;
281
362
  }): Promise<VaultLink>;
363
+ /**
364
+ * Hear every event Agentcard sends your company over one connection your server opens, with no
365
+ * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
366
+ */
367
+ listen(opts: ListenOptions): AgentcardListener;
282
368
  /** A company names the shopper exactly once; a shopper's own token already names them. */
283
369
  private checkShopper;
284
370
  private call;
package/dist/client.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * token cache follows its client (packages/checkout/src/client.ts accessToken).
10
10
  */
11
11
  import { isRecord } from './verify.js';
12
+ import { listenToAgentcard } from './listen.js';
13
+ const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
12
14
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
13
15
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
14
16
  export const BUY_TURN_TIMEOUT_MS = 130_000;
@@ -128,7 +130,19 @@ function catalogOf(v) {
128
130
  url: pageUrl(i.url),
129
131
  };
130
132
  }) : [];
131
- return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
133
+ return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
134
+ }
135
+ /** The stores an answer presents: each one with an id and a name, and no block when none has both. */
136
+ function storesOf(v) {
137
+ if (!isRecord(v) || !Array.isArray(v.items))
138
+ return null;
139
+ const items = v.items.filter(isRecord).flatMap((i) => {
140
+ const id = str(i.id);
141
+ const name = str(i.name);
142
+ const eta = num(i.eta_minutes);
143
+ return id && name ? [{ id, name, store_page_url: pageUrl(i.store_page_url), eta_minutes: eta }] : [];
144
+ });
145
+ return items.length ? { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, as_of: str(v.as_of) } : null;
132
146
  }
133
147
  function paymentSourceOf(v) {
134
148
  return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
@@ -163,6 +177,8 @@ export function turnOf(body) {
163
177
  store_login_merchant_name: str(body.store_login_merchant_name),
164
178
  next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
165
179
  pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
180
+ stores: storesOf(body.stores),
181
+ store_pick: isRecord(body.store_pick) && str(body.store_pick.store_id) ? { store_id: body.store_pick.store_id, name: str(body.store_pick.name), selected: body.store_pick.selected === true } : null,
166
182
  raw: body,
167
183
  };
168
184
  }
@@ -255,6 +271,106 @@ function parseJson(text) {
255
271
  return null;
256
272
  }
257
273
  }
274
+ /** Each event of a server-sent stream, in order, read as it arrives, or from the whole text when the fetch hands no stream. */
275
+ async function eachEvent(res, onEvent) {
276
+ let buffer = '';
277
+ // True once onEvent says the stream is over.
278
+ let over = false;
279
+ const drain = (final) => {
280
+ buffer = buffer.replace(/\r\n?/g, '\n');
281
+ for (let end = buffer.indexOf('\n\n'); end >= 0 || (final && buffer.trim() !== ''); end = buffer.indexOf('\n\n')) {
282
+ const block = end >= 0 ? buffer.slice(0, end) : buffer;
283
+ buffer = end >= 0 ? buffer.slice(end + 2) : '';
284
+ let event = 'message';
285
+ const data = [];
286
+ for (const line of block.split('\n')) {
287
+ if (line.startsWith('event:'))
288
+ event = line.slice(6).trim();
289
+ else if (line.startsWith('data:'))
290
+ data.push(line.slice(5).replace(/^ /, ''));
291
+ }
292
+ if (data.length > 0 && onEvent(event, data.join('\n')) === true) {
293
+ over = true;
294
+ return;
295
+ }
296
+ }
297
+ };
298
+ const reader = res.body?.getReader?.();
299
+ if (!reader) {
300
+ buffer = await res.text();
301
+ drain(true);
302
+ return;
303
+ }
304
+ const decoder = new TextDecoder();
305
+ try {
306
+ while (!over) {
307
+ const chunk = await reader.read();
308
+ if (chunk.done)
309
+ break;
310
+ buffer += decoder.decode(chunk.value, { stream: true });
311
+ drain(false);
312
+ }
313
+ if (!over)
314
+ drain(true);
315
+ }
316
+ finally {
317
+ if (over)
318
+ reader.cancel?.().catch(() => { });
319
+ }
320
+ }
321
+ /**
322
+ * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
323
+ * status in `http_status`. Each `progress` and `catalog` goes to its handler on the way. A stream that
324
+ * breaks or ends before its envelope, once it named the conversation, reads as a turn past its budget
325
+ * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
326
+ */
327
+ async function readStreamedTurn(res, stream, timeoutMs) {
328
+ let envelope = null;
329
+ let conversationId = null;
330
+ const delivering = [];
331
+ let failure = null;
332
+ try {
333
+ await eachEvent(res, (event, text) => {
334
+ const data = parseJson(text);
335
+ if (!data)
336
+ return;
337
+ conversationId = str(data.conversation_id) ?? conversationId;
338
+ if (event === 'envelope') {
339
+ envelope = data;
340
+ return true;
341
+ }
342
+ if (event === 'progress' && typeof data.stage === 'string' && PROGRESS_STAGES.has(data.stage) && stream.onProgress) {
343
+ try {
344
+ stream.onProgress({ stage: data.stage, conversationId: str(data.conversation_id) });
345
+ }
346
+ catch {
347
+ // The app's display failing never fails the turn.
348
+ }
349
+ }
350
+ else if (event === 'catalog' && stream.onCatalog && str(data.conversation_id)) {
351
+ const catalog = catalogOf(data.catalog);
352
+ if (!catalog || catalog.items.length === 0)
353
+ return;
354
+ const asOf = isRecord(data.catalog) ? str(data.catalog.as_of) : null;
355
+ delivering.push(Promise.resolve().then(() => stream.onCatalog({ conversationId: data.conversation_id, catalog, asOf })).catch(() => { }));
356
+ }
357
+ });
358
+ }
359
+ catch (error) {
360
+ failure = error;
361
+ }
362
+ await Promise.all(delivering);
363
+ const answered = envelope;
364
+ if (answered) {
365
+ const { http_status: httpStatus, ...body } = answered;
366
+ const status = typeof httpStatus === 'number' ? httpStatus : res.status;
367
+ return { status, ok: status >= 200 && status < 300, readable: true, body };
368
+ }
369
+ if (conversationId) {
370
+ return { status: 200, ok: true, readable: true, body: { conversation_id: conversationId, status: 'needs_input', reply: '', error_code: 'turn_timeout' } };
371
+ }
372
+ throw failure ? failureOf(failure, timeoutMs) : new AgentcardApiError(0, 'unreachable', 'Agentcard closed the purchase answer before it ended.');
373
+ }
258
374
  /** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
259
375
  export class AgentcardClient {
260
376
  baseUrl;
@@ -291,10 +407,15 @@ export class AgentcardClient {
291
407
  get sandbox() {
292
408
  return this.learnedSandbox;
293
409
  }
294
- /** The access token the client sends: the shopper's own, or the company's, exchanged once and renewed a minute before it expires. */
295
- async bearer() {
410
+ /**
411
+ * The access token the client sends: the shopper's own, or the company's, exchanged once and
412
+ * renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
413
+ */
414
+ async bearer(refused) {
296
415
  if (this.shopperToken !== null)
297
416
  return this.shopperToken;
417
+ if (refused !== undefined)
418
+ return this.renewAfter(refused);
298
419
  if (this.token && Date.now() < this.token.expiresAt)
299
420
  return this.token.value;
300
421
  if (!this.inflight)
@@ -307,11 +428,14 @@ export class AgentcardClient {
307
428
  * conversation instead. Only a 401 is sent again, once, with a fresh token,
308
429
  * because Agentcard refused it before anything ran.
309
430
  */
310
- async buy(body) {
431
+ async buy(body, stream) {
311
432
  if (!isRecord(body))
312
433
  throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
313
434
  this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
314
- return turnOf(await this.call('POST', '/buy', body, BUY_TURN_TIMEOUT_MS));
435
+ const streamed = !!stream && (typeof stream.onProgress === 'function' || typeof stream.onCatalog === 'function');
436
+ // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
437
+ const path = streamed ? '/buy?stream=1&soft_timeout=1' : '/buy';
438
+ return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined));
315
439
  }
316
440
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
317
441
  async conversation(conversationId, who = {}) {
@@ -339,6 +463,13 @@ export class AgentcardClient {
339
463
  throw new AgentcardApiError(200, 'invalid_answer', 'Agentcard answered the Vault link request without a link.', answer);
340
464
  return { id: str(answer.id) ?? '', url, expiresAt: str(answer.expires_at), reused: answer.reused === true };
341
465
  }
466
+ /**
467
+ * Hear every event Agentcard sends your company over one connection your server opens, with no
468
+ * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
469
+ */
470
+ listen(opts) {
471
+ return listenToAgentcard(this, opts);
472
+ }
342
473
  /** A company names the shopper exactly once; a shopper's own token already names them. */
343
474
  checkShopper(who) {
344
475
  const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
@@ -351,10 +482,10 @@ export class AgentcardClient {
351
482
  throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
352
483
  }
353
484
  }
354
- async call(method, path, body, timeoutMs) {
485
+ async call(method, path, body, timeoutMs, stream) {
355
486
  let token = await this.bearer();
356
487
  for (let attempt = 0;; attempt++) {
357
- const answer = await this.send(method, path, token, body, timeoutMs);
488
+ const answer = await this.send(method, path, token, body, timeoutMs, stream);
358
489
  if (answer.status === 401 && attempt === 0 && this.credentials) {
359
490
  token = await this.renewAfter(token);
360
491
  continue;
@@ -372,24 +503,39 @@ export class AgentcardClient {
372
503
  throw errorOf(answer.status, answer.body);
373
504
  }
374
505
  }
375
- async send(method, path, token, body, timeoutMs) {
506
+ async send(method, path, token, body, timeoutMs, stream) {
507
+ let res;
376
508
  try {
377
- const res = await this.fetchFn(`${this.baseUrl}${path}`, {
509
+ res = await this.fetchFn(`${this.baseUrl}${path}`, {
378
510
  method,
379
511
  headers: {
380
512
  authorization: `Bearer ${token}`,
381
- accept: 'application/json',
513
+ accept: stream ? 'text/event-stream, application/json' : 'application/json',
382
514
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
383
515
  },
384
516
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
385
517
  signal: AbortSignal.timeout(timeoutMs),
386
518
  });
387
- const parsed = parseJson(await res.text());
388
- return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
389
519
  }
390
520
  catch (error) {
391
521
  throw failureOf(error, timeoutMs);
392
522
  }
523
+ // Agentcard streams only once the turn starts: a refusal before it is plain JSON.
524
+ if (stream && /text\/event-stream/i.test(res.headers?.get?.('content-type') ?? ''))
525
+ return readStreamedTurn(res, stream, timeoutMs);
526
+ let text;
527
+ try {
528
+ text = await res.text();
529
+ }
530
+ catch (error) {
531
+ throw failureOf(error, timeoutMs);
532
+ }
533
+ const parsed = parseJson(text);
534
+ // A fetch that hands no headers still carries a streamed answer in its text: read it whole.
535
+ if (stream && parsed === null && /^(?:event|data):/m.test(text)) {
536
+ return readStreamedTurn({ status: res.status, ok: res.ok, text: async () => text }, stream, timeoutMs);
537
+ }
538
+ return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
393
539
  }
394
540
  /** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
395
541
  async renewAfter(refused) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with four required fields, `product_id` null unless the shopper
4
- * picks a product they were shown. The delivery address and the
3
+ * JSON Schema with five required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
5
5
  * shopper's id are construction options, never model inputs, so a model can
6
6
  * neither change where an order goes nor buy for someone else.
7
7
  */
@@ -9,8 +9,9 @@ export declare const BUY_TOOL_NAME = "buy";
9
9
  /** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
10
10
  export declare const BUY_TOOL_DESCRIPTION: string;
11
11
  /**
12
- * What the model sends: `ask`, `confirm` and `new_order` every time, and `product_id`, null unless the
13
- * shopper picks a product from the last result. Your own code may leave `product_id` out.
12
+ * What the model sends: `ask`, `confirm` and `new_order` every time, `product_id`, null unless the
13
+ * shopper picks a product from the last result, and `store_id`, null unless they pick a store from it.
14
+ * Your own code may leave `product_id` and `store_id` out.
14
15
  */
15
16
  export interface BuyToolInput {
16
17
  ask: string;
@@ -18,6 +19,8 @@ export interface BuyToolInput {
18
19
  new_order: boolean;
19
20
  /** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
20
21
  product_id?: string | null;
22
+ /** The `store_id` of the store the shopper picked from the last result's `stores`, or null. */
23
+ store_id?: string | null;
21
24
  }
22
25
  /** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
23
26
  export type BuyToolInputSchema = {
@@ -39,6 +42,10 @@ export type BuyToolInputSchema = {
39
42
  type: ['string', 'null'];
40
43
  description: string;
41
44
  };
45
+ store_id: {
46
+ type: ['string', 'null'];
47
+ description: string;
48
+ };
42
49
  };
43
50
  required: Array<keyof BuyToolInput>;
44
51
  additionalProperties: false;
@@ -52,8 +59,9 @@ export interface BuyToolDefinition {
52
59
  export declare function buyToolDefinition(): BuyToolDefinition;
53
60
  /**
54
61
  * The model's input, checked against the schema: an object, or its JSON text,
55
- * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
56
- * string, null or left out, and nothing else. Throws a TypeError whose message
62
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
63
+ * `store_id` each a string, null or left out, at most one of them set, and
64
+ * nothing else. Throws a TypeError whose message
57
65
  * tells the model what to send.
58
66
  */
59
67
  export declare function parseBuyToolInput(value: unknown): BuyToolInput;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with four required fields, `product_id` null unless the shopper
4
- * picks a product they were shown. The delivery address and the
3
+ * JSON Schema with five required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
5
5
  * shopper's id are construction options, never model inputs, so a model can
6
6
  * neither change where an order goes nor buy for someone else.
7
7
  */
@@ -11,6 +11,7 @@ export const BUY_TOOL_DESCRIPTION = [
11
11
  'Buy what this shopper asks for at real stores.',
12
12
  "Put the shopper's own words in `ask`.",
13
13
  'Tell the shopper what `reply` says, in your own voice.',
14
+ "When `shopper_already_told_looking` is true, the shopper was already told you are working on their request, so never acknowledge it again: open with the answer or the question.",
14
15
  'Show the shopper the cart before you confirm it.',
15
16
  'Ask the shopper for exactly one thing per message: the step `next_action` names, even when `reply` lists more. Never mention a later step, and never ask for the next one until the shopper has done this one.',
16
17
  'Set `confirm` to true only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
@@ -24,6 +25,8 @@ export const BUY_TOOL_DESCRIPTION = [
24
25
  "After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
25
26
  'Set `new_order` to true only when the shopper drops the cart.',
26
27
  "When the shopper picks one of the `products` in your last result, by its name, its place in the list or a tap that names it, set `product_id` to that product's `product_id`, so Agentcard adds exactly that product; otherwise set it to null.",
28
+ "When the shopper picks one of the `stores` in your last result, by its name, its place in the list or a tap that names it, set `store_id` to that store's `store_id`, so Agentcard orders from exactly that store; otherwise set it to null. Never set both `product_id` and `store_id`.",
29
+ 'Store links reach the shopper on their own, beside your reply, as product links do: `store_links_sent` says how many.',
27
30
  'When `pick.in_cart` is false, the product the shopper picked is not in the cart: tell the shopper what `reply` says, and never ask them to confirm the cart.',
28
31
  ].join(' ');
29
32
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
@@ -44,17 +47,22 @@ export function buyToolDefinition() {
44
47
  type: ['string', 'null'],
45
48
  description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
46
49
  },
50
+ store_id: {
51
+ type: ['string', 'null'],
52
+ description: 'The `store_id` of the store the shopper picked from the `stores` in your last result. Null when they picked none.',
53
+ },
47
54
  },
48
- required: ['ask', 'confirm', 'new_order', 'product_id'],
55
+ required: ['ask', 'confirm', 'new_order', 'product_id', 'store_id'],
49
56
  additionalProperties: false,
50
57
  },
51
58
  };
52
59
  }
53
- const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
60
+ const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id', 'store_id']);
54
61
  /**
55
62
  * The model's input, checked against the schema: an object, or its JSON text,
56
- * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
57
- * string, null or left out, and nothing else. Throws a TypeError whose message
63
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
64
+ * `store_id` each a string, null or left out, at most one of them set, and
65
+ * nothing else. Throws a TypeError whose message
58
66
  * tells the model what to send.
59
67
  */
60
68
  export function parseBuyToolInput(value) {
@@ -73,7 +81,7 @@ export function parseBuyToolInput(value) {
73
81
  const fields = input;
74
82
  const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
75
83
  if (extra !== undefined)
76
- throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
84
+ throw new TypeError(`The buy tool takes only ask, confirm, new_order, product_id and store_id, and ${extra} is not one of them.`);
77
85
  if (typeof fields.ask !== 'string')
78
86
  throw new TypeError("Set ask to the shopper's words, as a string.");
79
87
  if (typeof fields.confirm !== 'boolean')
@@ -84,7 +92,15 @@ export function parseBuyToolInput(value) {
84
92
  if (productId !== undefined && productId !== null && typeof productId !== 'string') {
85
93
  throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
86
94
  }
87
- return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
95
+ const storeId = fields.store_id;
96
+ if (storeId !== undefined && storeId !== null && typeof storeId !== 'string') {
97
+ throw new TypeError("Set store_id to the picked store's store_id, as a string, or to null.");
98
+ }
99
+ const product = typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null;
100
+ const store = typeof storeId === 'string' && storeId.trim() !== '' ? storeId.trim() : null;
101
+ if (product && store)
102
+ throw new TypeError('Set product_id or store_id for the one thing the shopper picked, and the other to null.');
103
+ return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: product, store_id: store };
88
104
  }
89
105
  /** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
90
106
  export function openAIBuyTool() {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Every event type Agentcard sends a company, the same list a webhook endpoint subscribes to by
3
+ * name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
4
+ * envelope and the same ids. A test holds this list to the backend's catalog.
5
+ */
6
+ export declare const AGENTCARD_EVENT_TYPES: readonly ["card.created", "card.updated", "card.closed", "cardholder.created", "cardholder.updated", "transaction.authorized", "transaction.declined", "transaction.cleared", "transaction.voided", "balance.low", "cardholder_onboarding_session.completed", "platform_connect_session.completed", "merchant.connected", "order.placed", "order.confirmed", "order.failed", "order.updated", "purchase.message", "connection.created", "connected_card.updated", "vault.card_stored", "vault.session_linked", "vault.session_card_picked", "vault.payment_permission.updated", "action.confirmed", "action.done", "checkout_authorization.approved", "checkout_authorization.submitted", "checkout_authorization.declined", "checkout_authorization.cancelled", "checkout_authorization.expired", "checkout_authorization.duplicate_charge", "checkout_authorization.refused", "checkout_authorization.watched", "checkout_authorization.amount_mismatch", "checkout_authorization.settled", "checkout_authorization.settlement_unconfirmed", "checkout_authorization.outcome_reported", "card_flow.started", "card_flow.failed", "transfer.approved", "transfer.initiated", "transfer.completed", "transfer.failed", "transfer.released", "wallet.funded", "user_wallet.funding_detected", "user_wallet.funded", "wallet.balance.low", "recovery.requested", "recovery.completed", "recovery.rejected", "wallet.withdrawal.initiated", "wallet.withdrawal.completed", "wallet.withdrawal.failed", "identity.verification.updated", "approval.requested", "reward.earned", "reward.reversed", "wallet_link.opened"];
7
+ /** One event type Agentcard sends. A type Agentcard adds later still reads as its own name. */
8
+ export type AgentcardEventType = (typeof AGENTCARD_EVENT_TYPES)[number];
9
+ /** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
10
+ export declare function isEventPattern(pattern: string): boolean;
11
+ /** Whether an event type matches a filter entry, as a webhook subscription matches it. */
12
+ export declare function matchesEventPattern(type: string, pattern: string): boolean;
package/dist/events.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Every event type Agentcard sends a company, the same list a webhook endpoint subscribes to by
3
+ * name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
4
+ * envelope and the same ids. A test holds this list to the backend's catalog.
5
+ */
6
+ export const AGENTCARD_EVENT_TYPES = [
7
+ 'card.created',
8
+ 'card.updated',
9
+ 'card.closed',
10
+ 'cardholder.created',
11
+ 'cardholder.updated',
12
+ 'transaction.authorized',
13
+ 'transaction.declined',
14
+ 'transaction.cleared',
15
+ 'transaction.voided',
16
+ 'balance.low',
17
+ 'cardholder_onboarding_session.completed',
18
+ 'platform_connect_session.completed',
19
+ 'merchant.connected',
20
+ 'order.placed',
21
+ 'order.confirmed',
22
+ 'order.failed',
23
+ 'order.updated',
24
+ 'purchase.message',
25
+ 'connection.created',
26
+ 'connected_card.updated',
27
+ 'vault.card_stored',
28
+ 'vault.session_linked',
29
+ 'vault.session_card_picked',
30
+ 'vault.payment_permission.updated',
31
+ 'action.confirmed',
32
+ 'action.done',
33
+ 'checkout_authorization.approved',
34
+ 'checkout_authorization.submitted',
35
+ 'checkout_authorization.declined',
36
+ 'checkout_authorization.cancelled',
37
+ 'checkout_authorization.expired',
38
+ 'checkout_authorization.duplicate_charge',
39
+ 'checkout_authorization.refused',
40
+ 'checkout_authorization.watched',
41
+ 'checkout_authorization.amount_mismatch',
42
+ 'checkout_authorization.settled',
43
+ 'checkout_authorization.settlement_unconfirmed',
44
+ 'checkout_authorization.outcome_reported',
45
+ 'card_flow.started',
46
+ 'card_flow.failed',
47
+ 'transfer.approved',
48
+ 'transfer.initiated',
49
+ 'transfer.completed',
50
+ 'transfer.failed',
51
+ 'transfer.released',
52
+ 'wallet.funded',
53
+ 'user_wallet.funding_detected',
54
+ 'user_wallet.funded',
55
+ 'wallet.balance.low',
56
+ 'recovery.requested',
57
+ 'recovery.completed',
58
+ 'recovery.rejected',
59
+ 'wallet.withdrawal.initiated',
60
+ 'wallet.withdrawal.completed',
61
+ 'wallet.withdrawal.failed',
62
+ 'identity.verification.updated',
63
+ 'approval.requested',
64
+ 'reward.earned',
65
+ 'reward.reversed',
66
+ 'wallet_link.opened',
67
+ ];
68
+ const KNOWN = new Set(AGENTCARD_EVENT_TYPES);
69
+ const FAMILIES = new Set(AGENTCARD_EVENT_TYPES.map((t) => `${t.split('.')[0]}.*`));
70
+ /** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
71
+ export function isEventPattern(pattern) {
72
+ return pattern === '*' || KNOWN.has(pattern) || FAMILIES.has(pattern);
73
+ }
74
+ /** Whether an event type matches a filter entry, as a webhook subscription matches it. */
75
+ export function matchesEventPattern(type, pattern) {
76
+ if (pattern === '*' || pattern === type)
77
+ return true;
78
+ return pattern.endsWith('.*') && type.startsWith(pattern.slice(0, -1));
79
+ }