@agent-cards/commerce-sdk 0.5.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.
package/dist/buy-tool.js CHANGED
@@ -24,9 +24,9 @@
24
24
  */
25
25
  import { AgentcardApiError, AgentcardClient, } from './client.js';
26
26
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
27
- import { forModel, forModelError, money, shownProducts } from './model-view.js';
27
+ import { forModel, forModelError, money, pickId, shownProducts } from './model-view.js';
28
28
  import { toMessages } from './messages.js';
29
- import { ackAt, isNewRequest, nextTemplate } from './ack.js';
29
+ import { ackAt, instantAck as instantAckOf, isNewRequest, nextTemplate } from './ack.js';
30
30
  /** The pause before the one read that follows a placement Agentcard is still finishing. */
31
31
  const PLACEMENT_WAIT_MS = 12_000;
32
32
  /** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
@@ -46,6 +46,8 @@ const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 't
46
46
  const ACK_DELAY_MS = 1_500;
47
47
  /** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
48
48
  const SAME_TURN_MS = 15_000;
49
+ /** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
50
+ const APP_TURN_MS = 120_000;
49
51
  const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
50
52
  const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
51
53
  const STILL_PLACING = 'The shopper approved, and the order is still going through.';
@@ -73,7 +75,11 @@ function hashesOf(cart, carts) {
73
75
  return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
74
76
  }
75
77
  function productsOf(turn) {
76
- return (turn.catalog?.items ?? [])
78
+ return turn.catalog ? productsOfCatalog(turn.catalog) : [];
79
+ }
80
+ /** The products of a search, as the tool reads them. */
81
+ function productsOfCatalog(catalog) {
82
+ return (catalog.items ?? [])
77
83
  .filter((item) => item.name)
78
84
  .map((item) => ({
79
85
  id: item.id,
@@ -83,7 +89,7 @@ function productsOf(turn) {
83
89
  currency: item.currency,
84
90
  imageUrl: item.image_url,
85
91
  ...(item.flight ? { flight: item.flight } : {}),
86
- store: item.store?.name ?? item.store?.id ?? turn.catalog?.merchant_name ?? null,
92
+ store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
87
93
  url: item.url ?? null,
88
94
  linkSent: false,
89
95
  }));
@@ -318,6 +324,19 @@ export function buyTool(options) {
318
324
  throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
319
325
  }
320
326
  const sendLink = o.sendLink;
327
+ if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
328
+ throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
329
+ if (o.stream !== undefined && o.stream !== false)
330
+ throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
331
+ const onProgress = o.onProgress;
332
+ if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
333
+ throw new TypeError('Pass earlyCards as true or false.');
334
+ const earlyCards = o.earlyCards === true && !!o.sendLink;
335
+ const streams = o.stream !== false && (earlyCards || !!onProgress);
336
+ if (o.onResult !== undefined && typeof o.onResult !== 'function') {
337
+ throw new TypeError('Pass onResult as a function that takes each answer.');
338
+ }
339
+ const onResult = o.onResult;
321
340
  const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
322
341
  if (typeof wait !== 'function')
323
342
  throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
@@ -336,8 +355,29 @@ export function buyTool(options) {
336
355
  let lastEndedAt = Number.NEGATIVE_INFINITY;
337
356
  let turnAcked = false;
338
357
  let lastTemplate = -1;
358
+ /** Counts the model's turns: the calls of one turn share its number. */
359
+ let modelTurn = 0;
339
360
  /** This turn's instant reply while it is being sent: every call of the turn waits for it before it hands over a link. */
340
361
  let ackOut = null;
362
+ /** True once `onWorking` delivered this turn's instant reply, so the model is told not to acknowledge again. */
363
+ let ackDelivered = false;
364
+ /**
365
+ * The app's instant reply for the shopper's newest message, waiting for the model's first call on that
366
+ * message, and the step the last answer asked the shopper for. A call still running for an earlier
367
+ * message keeps its own turn's state.
368
+ */
369
+ let appAck = null;
370
+ let lastNextAction = null;
371
+ /** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
372
+ const instantAck = (shopperText) => {
373
+ const text = String(shopperText ?? '');
374
+ const said = instantAckOf(text, { nextAction: lastNextAction }) !== null;
375
+ // The same rotation onWorking uses, so the next reply comes from another template.
376
+ if (said)
377
+ lastTemplate = nextTemplate(lastTemplate);
378
+ appAck = { at: Date.now(), said };
379
+ return said ? ackAt(lastTemplate, text) : null;
380
+ };
341
381
  /**
342
382
  * Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
343
383
  * not gone out, and frees the turn's reply for a later call.
@@ -367,7 +407,9 @@ export function buyTool(options) {
367
407
  return;
368
408
  }
369
409
  const message = text.trim();
370
- ackOut = Promise.resolve().then(() => onWorking(message)).catch(() => { });
410
+ ackOut = Promise.resolve()
411
+ .then(() => onWorking(message))
412
+ .then(() => { ackDelivered = true; }, () => { });
371
413
  }, ackDelayMs);
372
414
  return {
373
415
  cancel: () => {
@@ -594,15 +636,21 @@ export function buyTool(options) {
594
636
  // A running turn that placed nothing may be a search rather than a placement, so the shopper hears it is still working.
595
637
  return settled.status === 'placing' && !placing ? refused : settled;
596
638
  };
597
- /** The store pages this tool sent last, with their conversation. */
639
+ /** The store pages this tool sent last, with their conversation, the model turn that sent them, and the search they list. */
598
640
  let lastPages = null;
599
641
  /**
600
642
  * The store page of each product the model reads, sent once per page. A
601
643
  * page the last batch in this conversation already sent stays with the
602
- * shopper and goes out no second time. A page whose `sendLink` throws
644
+ * shopper and goes out no second time, unless a later model turn's reply
645
+ * presents the products again (the catalog's `presented`): a repeated ask
646
+ * answered with the same products gets their pages beside it, so every
647
+ * product the reply names has a card. Calls of one model turn still send a
648
+ * page once. An answer that lists fewer of the same search's products, such
649
+ * as a pick's, keeps the pages that search already sent, so a later answer
650
+ * that lists them again sends none twice. A page whose `sendLink` throws
603
651
  * stays unsent, and the call goes on, so the next answer tries it again.
604
652
  */
605
- const deliverPages = async (result) => {
653
+ const deliverPages = async (result, call) => {
606
654
  if (!sendLink)
607
655
  return result;
608
656
  const pages = new Map();
@@ -613,12 +661,21 @@ export function buyTool(options) {
613
661
  // An answer with no store page leaves the last batch as it was.
614
662
  if (pages.size === 0)
615
663
  return result;
616
- const before = lastPages?.conversationId === result.conversation_id ? lastPages.urls : new Set();
617
- const held = new Set();
664
+ const kept = lastPages?.conversationId === result.conversation_id && (!call.presented || lastPages.turn === call.turn);
665
+ const before = kept && lastPages ? lastPages.urls : new Set();
666
+ // Agentcard names the search an answer lists by when it ran. An answer that lists fewer of the same
667
+ // search's products, such as a pick's, keeps every page that search already sent, so a later answer
668
+ // that lists them again sends none twice.
669
+ const catalog = result.envelope?.catalog;
670
+ const search = typeof catalog?.as_of === 'string' ? catalog.as_of : null;
671
+ const sameSearch = kept && search !== null && lastPages?.search === search;
672
+ const held = new Set(sameSearch ? before : []);
618
673
  for (const [url, product] of pages) {
619
674
  if (!before.has(url)) {
620
675
  try {
621
- await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, imageUrl: product.imageUrl, expiresAt: null });
676
+ // The id the model reads and a pick takes, never the store page: the page is `url`.
677
+ const productId = pickId(product);
678
+ await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
622
679
  }
623
680
  catch {
624
681
  continue;
@@ -626,25 +683,27 @@ export function buyTool(options) {
626
683
  }
627
684
  held.add(url);
628
685
  }
629
- lastPages = { conversationId: result.conversation_id, urls: held };
686
+ lastPages = { conversationId: result.conversation_id, turn: call.turn, search, urls: held };
630
687
  return { ...result, products: result.products.map((product) => (product.url && held.has(product.url) ? { ...product, linkSent: true } : product)) };
631
688
  };
689
+ /** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
690
+ const earlyPages = (conversationId, asOf, products) => ({ conversation_id: conversationId, envelope: { catalog: { as_of: asOf } }, products });
632
691
  /** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
633
692
  let pagesInTurn = Promise.resolve();
634
- const deliverPagesInTurn = (result) => {
635
- const batch = pagesInTurn.then(() => deliverPages(result));
693
+ const deliverPagesInTurn = (result, call) => {
694
+ const batch = pagesInTurn.then(() => deliverPages(result, call));
636
695
  pagesInTurn = batch.catch(() => { });
637
696
  return batch;
638
697
  };
639
698
  /** The Vault or approval link first, because the purchase waits on it, then the store pages. */
640
- const deliver = async (result) => {
699
+ const deliver = async (result, call) => {
641
700
  let delivered = result;
642
701
  if (result.links.length > 0 && sendLink) {
643
702
  for (const link of result.links)
644
703
  await sendLink({ ...link });
645
704
  delivered = { ...result, link_sent: true };
646
705
  }
647
- return deliverPagesInTurn(delivered);
706
+ return deliverPagesInTurn(delivered, call);
648
707
  };
649
708
  const answer = async (raw) => {
650
709
  const startedAt = Date.now();
@@ -664,19 +723,52 @@ export function buyTool(options) {
664
723
  ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
665
724
  ...(client.company ? { external_user_id: externalUserId } : {}),
666
725
  };
667
- // A call that starts while another runs, or right after one ended, is the model working through the same turn.
668
- if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
726
+ // The first call after `instantAck` opens the turn of the message it answered, whose reply is the
727
+ // app's. Any other call that starts while another runs, or right after one ended, is the model
728
+ // working through the same turn.
729
+ const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
730
+ appAck = null;
731
+ if (opened) {
732
+ turnAcked = true;
733
+ ackOut = null;
734
+ ackDelivered = opened.said;
735
+ modelTurn += 1;
736
+ }
737
+ else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
669
738
  turnAcked = false;
670
739
  ackOut = null;
740
+ ackDelivered = false;
741
+ modelTurn += 1;
671
742
  }
743
+ const callTurn = modelTurn;
672
744
  running += 1;
673
745
  // A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
674
746
  const ack = confirm || pick ? null : holdAck(ask);
675
747
  try {
676
748
  let turn = null;
677
749
  let refusal;
750
+ // Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
751
+ const handlers = streams
752
+ ? {
753
+ ...(onProgress ? { onProgress: (progress) => { try {
754
+ onProgress(progress);
755
+ }
756
+ catch { /* the app's display failing never fails the call */ } } } : {}),
757
+ ...(earlyCards && sendLink && !confirm
758
+ ? {
759
+ onCatalog: async (update) => {
760
+ // Products arrived before the instant reply was due: they are the reply.
761
+ ack?.cancel();
762
+ await ackOut;
763
+ // The turn's own search presents its products, as a reply that lists them does.
764
+ await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true });
765
+ },
766
+ }
767
+ : {}),
768
+ }
769
+ : undefined;
678
770
  try {
679
- turn = await client.buy(body);
771
+ turn = await client.buy(body, handlers);
680
772
  }
681
773
  catch (error) {
682
774
  refusal = error;
@@ -684,7 +776,18 @@ export function buyTool(options) {
684
776
  ack?.cancel();
685
777
  const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
686
778
  await ackOut;
687
- return await deliver(withMessages(settled, turn));
779
+ const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true });
780
+ const result = ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
781
+ lastNextAction = result.next_action;
782
+ if (onResult) {
783
+ try {
784
+ await onResult(result);
785
+ }
786
+ catch {
787
+ // The app's display failing never fails the purchase call.
788
+ }
789
+ }
790
+ return result;
688
791
  }
689
792
  finally {
690
793
  ack?.cancel();
@@ -711,7 +814,7 @@ export function buyTool(options) {
711
814
  }
712
815
  };
713
816
  const { name, description, input_schema } = buyToolDefinition();
714
- const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel };
817
+ const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel, instantAck };
715
818
  // JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
716
819
  const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
717
820
  return Object.defineProperties(tool, {
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;
@@ -119,11 +160,16 @@ export interface BuyCatalogItem {
119
160
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
120
161
  url: string | null;
121
162
  }
122
- /** The products the last search found. */
163
+ /**
164
+ * The products the last search found. `presented` is true when this answer's reply presents them: the
165
+ * turn searched, or answered a repeated ask from the results it already held. False when they only
166
+ * stand from an earlier answer, and on an older API.
167
+ */
123
168
  export interface BuyCatalog {
124
169
  merchant: string;
125
170
  merchant_name: string;
126
171
  items: BuyCatalogItem[];
172
+ presented: boolean;
127
173
  }
128
174
  /** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
129
175
  export interface BuyPick {
@@ -260,15 +306,18 @@ export declare class AgentcardClient {
260
306
  get company(): boolean;
261
307
  /** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
262
308
  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>;
309
+ /**
310
+ * The access token the client sends: the shopper's own, or the company's, exchanged once and
311
+ * renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
312
+ */
313
+ bearer(refused?: string): Promise<string>;
265
314
  /**
266
315
  * One purchase turn, sent once with a 130-second wait. A turn is a model run
267
316
  * against live stores, so a lost answer is never sent again: read the
268
317
  * conversation instead. Only a 401 is sent again, once, with a fresh token,
269
318
  * because Agentcard refused it before anything ran.
270
319
  */
271
- buy(body: BuyRequest): Promise<BuyTurn>;
320
+ buy(body: BuyRequest, stream?: BuyStreamHandlers): Promise<BuyTurn>;
272
321
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
273
322
  conversation(conversationId: string, who?: Shopper): Promise<BuyConversation>;
274
323
  /**
@@ -279,6 +328,11 @@ export declare class AgentcardClient {
279
328
  mintVaultLink(input: {
280
329
  externalUserId: string;
281
330
  }): Promise<VaultLink>;
331
+ /**
332
+ * Hear every event Agentcard sends your company over one connection your server opens, with no
333
+ * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
334
+ */
335
+ listen(opts: ListenOptions): AgentcardListener;
282
336
  /** A company names the shopper exactly once; a shopper's own token already names them. */
283
337
  private checkShopper;
284
338
  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,7 @@ 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 };
132
134
  }
133
135
  function paymentSourceOf(v) {
134
136
  return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
@@ -255,6 +257,106 @@ function parseJson(text) {
255
257
  return null;
256
258
  }
257
259
  }
260
+ /** Each event of a server-sent stream, in order, read as it arrives, or from the whole text when the fetch hands no stream. */
261
+ async function eachEvent(res, onEvent) {
262
+ let buffer = '';
263
+ // True once onEvent says the stream is over.
264
+ let over = false;
265
+ const drain = (final) => {
266
+ buffer = buffer.replace(/\r\n?/g, '\n');
267
+ for (let end = buffer.indexOf('\n\n'); end >= 0 || (final && buffer.trim() !== ''); end = buffer.indexOf('\n\n')) {
268
+ const block = end >= 0 ? buffer.slice(0, end) : buffer;
269
+ buffer = end >= 0 ? buffer.slice(end + 2) : '';
270
+ let event = 'message';
271
+ const data = [];
272
+ for (const line of block.split('\n')) {
273
+ if (line.startsWith('event:'))
274
+ event = line.slice(6).trim();
275
+ else if (line.startsWith('data:'))
276
+ data.push(line.slice(5).replace(/^ /, ''));
277
+ }
278
+ if (data.length > 0 && onEvent(event, data.join('\n')) === true) {
279
+ over = true;
280
+ return;
281
+ }
282
+ }
283
+ };
284
+ const reader = res.body?.getReader?.();
285
+ if (!reader) {
286
+ buffer = await res.text();
287
+ drain(true);
288
+ return;
289
+ }
290
+ const decoder = new TextDecoder();
291
+ try {
292
+ while (!over) {
293
+ const chunk = await reader.read();
294
+ if (chunk.done)
295
+ break;
296
+ buffer += decoder.decode(chunk.value, { stream: true });
297
+ drain(false);
298
+ }
299
+ if (!over)
300
+ drain(true);
301
+ }
302
+ finally {
303
+ if (over)
304
+ reader.cancel?.().catch(() => { });
305
+ }
306
+ }
307
+ /**
308
+ * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
309
+ * status in `http_status`. Each `progress` and `catalog` goes to its handler on the way. A stream that
310
+ * breaks or ends before its envelope, once it named the conversation, reads as a turn past its budget
311
+ * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
312
+ */
313
+ async function readStreamedTurn(res, stream, timeoutMs) {
314
+ let envelope = null;
315
+ let conversationId = null;
316
+ const delivering = [];
317
+ let failure = null;
318
+ try {
319
+ await eachEvent(res, (event, text) => {
320
+ const data = parseJson(text);
321
+ if (!data)
322
+ return;
323
+ conversationId = str(data.conversation_id) ?? conversationId;
324
+ if (event === 'envelope') {
325
+ envelope = data;
326
+ return true;
327
+ }
328
+ if (event === 'progress' && typeof data.stage === 'string' && PROGRESS_STAGES.has(data.stage) && stream.onProgress) {
329
+ try {
330
+ stream.onProgress({ stage: data.stage, conversationId: str(data.conversation_id) });
331
+ }
332
+ catch {
333
+ // The app's display failing never fails the turn.
334
+ }
335
+ }
336
+ else if (event === 'catalog' && stream.onCatalog && str(data.conversation_id)) {
337
+ const catalog = catalogOf(data.catalog);
338
+ if (!catalog || catalog.items.length === 0)
339
+ return;
340
+ const asOf = isRecord(data.catalog) ? str(data.catalog.as_of) : null;
341
+ delivering.push(Promise.resolve().then(() => stream.onCatalog({ conversationId: data.conversation_id, catalog, asOf })).catch(() => { }));
342
+ }
343
+ });
344
+ }
345
+ catch (error) {
346
+ failure = error;
347
+ }
348
+ await Promise.all(delivering);
349
+ const answered = envelope;
350
+ if (answered) {
351
+ const { http_status: httpStatus, ...body } = answered;
352
+ const status = typeof httpStatus === 'number' ? httpStatus : res.status;
353
+ return { status, ok: status >= 200 && status < 300, readable: true, body };
354
+ }
355
+ if (conversationId) {
356
+ return { status: 200, ok: true, readable: true, body: { conversation_id: conversationId, status: 'needs_input', reply: '', error_code: 'turn_timeout' } };
357
+ }
358
+ throw failure ? failureOf(failure, timeoutMs) : new AgentcardApiError(0, 'unreachable', 'Agentcard closed the purchase answer before it ended.');
359
+ }
258
360
  /** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
259
361
  export class AgentcardClient {
260
362
  baseUrl;
@@ -291,10 +393,15 @@ export class AgentcardClient {
291
393
  get sandbox() {
292
394
  return this.learnedSandbox;
293
395
  }
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() {
396
+ /**
397
+ * The access token the client sends: the shopper's own, or the company's, exchanged once and
398
+ * renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
399
+ */
400
+ async bearer(refused) {
296
401
  if (this.shopperToken !== null)
297
402
  return this.shopperToken;
403
+ if (refused !== undefined)
404
+ return this.renewAfter(refused);
298
405
  if (this.token && Date.now() < this.token.expiresAt)
299
406
  return this.token.value;
300
407
  if (!this.inflight)
@@ -307,11 +414,14 @@ export class AgentcardClient {
307
414
  * conversation instead. Only a 401 is sent again, once, with a fresh token,
308
415
  * because Agentcard refused it before anything ran.
309
416
  */
310
- async buy(body) {
417
+ async buy(body, stream) {
311
418
  if (!isRecord(body))
312
419
  throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
313
420
  this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
314
- return turnOf(await this.call('POST', '/buy', body, BUY_TURN_TIMEOUT_MS));
421
+ const streamed = !!stream && (typeof stream.onProgress === 'function' || typeof stream.onCatalog === 'function');
422
+ // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
423
+ const path = streamed ? '/buy?stream=1&soft_timeout=1' : '/buy';
424
+ return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined));
315
425
  }
316
426
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
317
427
  async conversation(conversationId, who = {}) {
@@ -339,6 +449,13 @@ export class AgentcardClient {
339
449
  throw new AgentcardApiError(200, 'invalid_answer', 'Agentcard answered the Vault link request without a link.', answer);
340
450
  return { id: str(answer.id) ?? '', url, expiresAt: str(answer.expires_at), reused: answer.reused === true };
341
451
  }
452
+ /**
453
+ * Hear every event Agentcard sends your company over one connection your server opens, with no
454
+ * webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
455
+ */
456
+ listen(opts) {
457
+ return listenToAgentcard(this, opts);
458
+ }
342
459
  /** A company names the shopper exactly once; a shopper's own token already names them. */
343
460
  checkShopper(who) {
344
461
  const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
@@ -351,10 +468,10 @@ export class AgentcardClient {
351
468
  throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
352
469
  }
353
470
  }
354
- async call(method, path, body, timeoutMs) {
471
+ async call(method, path, body, timeoutMs, stream) {
355
472
  let token = await this.bearer();
356
473
  for (let attempt = 0;; attempt++) {
357
- const answer = await this.send(method, path, token, body, timeoutMs);
474
+ const answer = await this.send(method, path, token, body, timeoutMs, stream);
358
475
  if (answer.status === 401 && attempt === 0 && this.credentials) {
359
476
  token = await this.renewAfter(token);
360
477
  continue;
@@ -372,24 +489,39 @@ export class AgentcardClient {
372
489
  throw errorOf(answer.status, answer.body);
373
490
  }
374
491
  }
375
- async send(method, path, token, body, timeoutMs) {
492
+ async send(method, path, token, body, timeoutMs, stream) {
493
+ let res;
376
494
  try {
377
- const res = await this.fetchFn(`${this.baseUrl}${path}`, {
495
+ res = await this.fetchFn(`${this.baseUrl}${path}`, {
378
496
  method,
379
497
  headers: {
380
498
  authorization: `Bearer ${token}`,
381
- accept: 'application/json',
499
+ accept: stream ? 'text/event-stream, application/json' : 'application/json',
382
500
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
383
501
  },
384
502
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
385
503
  signal: AbortSignal.timeout(timeoutMs),
386
504
  });
387
- const parsed = parseJson(await res.text());
388
- return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
389
505
  }
390
506
  catch (error) {
391
507
  throw failureOf(error, timeoutMs);
392
508
  }
509
+ // Agentcard streams only once the turn starts: a refusal before it is plain JSON.
510
+ if (stream && /text\/event-stream/i.test(res.headers?.get?.('content-type') ?? ''))
511
+ return readStreamedTurn(res, stream, timeoutMs);
512
+ let text;
513
+ try {
514
+ text = await res.text();
515
+ }
516
+ catch (error) {
517
+ throw failureOf(error, timeoutMs);
518
+ }
519
+ const parsed = parseJson(text);
520
+ // A fetch that hands no headers still carries a streamed answer in its text: read it whole.
521
+ if (stream && parsed === null && /^(?:event|data):/m.test(text)) {
522
+ return readStreamedTurn({ status: res.status, ok: res.ok, text: async () => text }, stream, timeoutMs);
523
+ }
524
+ return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
393
525
  }
394
526
  /** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
395
527
  async renewAfter(refused) {
@@ -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.',
@@ -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;