@agent-cards/commerce-sdk 0.11.0 → 0.14.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
@@ -44,20 +44,118 @@ export interface BuyCatalogUpdate {
44
44
  catalog: BuyCatalog;
45
45
  /** When the search ran, as the answer's `catalog.as_of` names it: the same search in the answer has the same value. */
46
46
  asOf: string | null;
47
+ /** How many of the products found are in `catalog.items`, the cards to show. Null when Agentcard sent no count. */
48
+ shown: number | null;
49
+ /** How many distinct products the turn's searches found so far. Null when Agentcard sent no count. */
50
+ found: number | null;
51
+ /** The products found past the cards, in the order they were found, to show on "show more" with no new search. */
52
+ more: BuyCatalog['items'];
47
53
  }
48
- /** Called while a purchase turn runs. Passing either one asks Agentcard to stream the turn. */
54
+ /** How many products a running turn found so far, and how many of them it shows: "8 of 23 shown". */
55
+ export interface BuyFoundUpdate {
56
+ conversationId: string;
57
+ shown: number;
58
+ found: number;
59
+ }
60
+ /**
61
+ * What a running turn is doing, the moment a step starts: `text` is a short line to show in place of loading dots, such as
62
+ * `searching Target and Walmart` or `adding it to your cart`, and `step` names the step for an app that writes its own,
63
+ * such as `searching`, `adding_to_cart` or `store_checkout`. No status comes for a turn that only writes its reply.
64
+ */
65
+ export interface BuyTurnStatus {
66
+ conversationId: string | null;
67
+ step: string;
68
+ text: string;
69
+ }
70
+ /** One message Agentcard finished writing while the turn runs, links included, as the answer's `messages` then holds it. */
71
+ export interface BuySegmentUpdate {
72
+ conversationId: string | null;
73
+ text: string;
74
+ }
75
+ /** The carts on the table the moment a running turn builds or changes one, before the message that announces it. */
76
+ export interface BuyCartUpdate {
77
+ conversationId: string;
78
+ /** The cart a confirm would place, as the answer's `cart` names it, or null when none would be. */
79
+ cart: BuyCart | null;
80
+ /** Every cart on the table, oldest first, as the answer's `carts`. */
81
+ carts: BuyCart[];
82
+ }
83
+ /**
84
+ * A piece of a message while Agentcard writes it. A piece can hold words Agentcard drops before the message is done, such
85
+ * as a line that only says it is looking, so show the pieces as a draft and replace them with each `onSegment` message.
86
+ */
87
+ export interface BuyDeltaUpdate {
88
+ conversationId: string | null;
89
+ text: string;
90
+ }
91
+ /**
92
+ * Called while a purchase turn runs. Passing any one asks Agentcard to stream the turn. `onCatalog`, `onSegment`,
93
+ * `onCart` and `onDelta` hear their events one at a time, in the order Agentcard sent them, each once the one before it
94
+ * settled, so a piece of the next message never reaches the app before the message ahead of it. The client waits for
95
+ * the last of them before it hands back the turn's answer.
96
+ */
49
97
  export interface BuyStreamHandlers {
50
98
  onProgress?: (progress: BuyProgress) => void;
51
- /** The client waits for what this returns before it hands back the turn's answer. */
99
+ /** Each step the turn starts, as a line to show while the shopper waits. */
100
+ onStatus?: (status: BuyTurnStatus) => void;
101
+ /** How many products the turn found, each time the count grows. */
102
+ onFound?: (update: BuyFoundUpdate) => void | Promise<void>;
52
103
  onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
104
+ /** Each message Agentcard writes, the moment it is written. */
105
+ onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
106
+ /** The carts, each time the turn builds or changes one. */
107
+ onCart?: (update: BuyCartUpdate) => void | Promise<void>;
108
+ /** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
109
+ onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
53
110
  }
54
111
  export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
55
112
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
56
113
  export declare const BUY_TURN_TIMEOUT_MS = 130000;
114
+ /** The W3C trace context a `POST /buy` carries, so the backend's spans join the caller's trace. */
115
+ export interface TraceContext {
116
+ traceparent: string;
117
+ tracestate?: string;
118
+ }
119
+ /** The part of `@opentelemetry/api` the client calls: pass the module itself as `otel`. */
120
+ export interface OtelApi {
121
+ trace: {
122
+ getTracer(name: string): OtelTracer;
123
+ };
124
+ context: {
125
+ active(): unknown;
126
+ };
127
+ propagation: {
128
+ inject(context: unknown, carrier: Record<string, string>): void;
129
+ };
130
+ }
131
+ interface OtelTracer {
132
+ startActiveSpan<T>(name: string, options: {
133
+ kind?: number;
134
+ attributes?: Record<string, string | boolean>;
135
+ }, fn: (span: OtelSpan) => T): T;
136
+ }
137
+ interface OtelSpan {
138
+ setAttribute(key: string, value: string | number | boolean): unknown;
139
+ setStatus(status: {
140
+ code: number;
141
+ message?: string;
142
+ }): unknown;
143
+ end(): void;
144
+ }
145
+ /**
146
+ * How the client finds the trace a `POST /buy` belongs to. `otel` is `@opentelemetry/api`: by default the client
147
+ * loads it when the app installed it, and `false` turns that off. `traceContext` names the trace by hand, for an app
148
+ * without OpenTelemetry: return its `traceparent`, or nothing to send none. The active OpenTelemetry context wins.
149
+ */
150
+ export interface AgentcardTraceOptions {
151
+ otel?: OtelApi | false;
152
+ traceContext?: () => TraceContext | string | null | undefined;
153
+ }
57
154
  /** A company's client credentials, or one shopper's own access token. */
58
155
  export type AgentcardClientOptions = {
59
156
  baseUrl?: string;
60
157
  fetch?: FetchLike;
158
+ trace?: AgentcardTraceOptions;
61
159
  } & ({
62
160
  clientId: string;
63
161
  clientSecret: string;
@@ -91,6 +189,8 @@ export interface BuyRequest {
91
189
  confirm?: string | true;
92
190
  /** Continue the shopper's newest open conversation instead of starting one. */
93
191
  resume?: true;
192
+ /** The conversation a confirm of a cart hash belongs to, in place of `resume`. */
193
+ conversation_id?: string;
94
194
  /** Drop the cart on the table and start a new order. */
95
195
  new_order?: true;
96
196
  /**
@@ -117,8 +217,23 @@ export interface BuyStore {
117
217
  export interface BuyCartLine {
118
218
  name: string;
119
219
  qty: number;
220
+ /** One unit's price, as the store lists it. */
120
221
  priceCents?: number;
222
+ /**
223
+ * What the line costs: its price with every add-on, times its quantity, before the store's own fees.
224
+ * The lines' totals add up to the cart's `subtotalCents`. Null when its price is unknown.
225
+ */
226
+ lineTotalCents: number | null;
227
+ /** The choices made on the line, by the store's names ("Carnitas", "Extra Guacamole"). Empty when none. */
228
+ options: string[];
229
+ /** The variant the line is, by the store's name ("Birthday (Medium)"), or null when the store names none. */
230
+ variant: string | null;
121
231
  product_id: string | null;
232
+ /**
233
+ * The id of the product card this line is, as `BuyProduct.ref` names it: a shown product whose `ref`
234
+ * (else `id`) equals it is in the cart. Null from an older API.
235
+ */
236
+ ref: string | null;
122
237
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
123
238
  url: string | null;
124
239
  }
@@ -138,7 +253,8 @@ export interface BuyCart {
138
253
  items: BuyCartLine[];
139
254
  /** The items alone, or null from an older API. */
140
255
  subtotalCents: number | null;
141
- serviceFeesCents: number;
256
+ serviceFeesCents: number | null;
257
+ serviceFeesStatus?: 'known' | 'pending' | 'unknown';
142
258
  /** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
143
259
  merchantFeeLines: BuyCartFeeLine[] | null;
144
260
  tipCents: number;
@@ -172,9 +288,16 @@ export interface BuyCatalogItem {
172
288
  ref: string | null;
173
289
  name: string | null;
174
290
  priceCents: number | null;
291
+ /** True when `priceCents` is a "from" price: the lowest price of a product priced by a required choice, such as a burger's patty count or a drink's size. */
292
+ price_from?: true;
175
293
  /** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
176
294
  currency: string | null;
177
295
  image_url: string | null;
296
+ /**
297
+ * Every photo of the product Agentcard sent, `image_url` first, each once, at most eight. One entry when the
298
+ * product has one photo, and none when it has none. An older API sends one photo at most.
299
+ */
300
+ image_urls: string[];
178
301
  store: BuyStore | null;
179
302
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
180
303
  url: string | null;
@@ -209,7 +332,7 @@ export interface BuyStoreChoice {
209
332
  eta_minutes: number | null;
210
333
  image_url: string | null;
211
334
  }
212
- /** The stores an answer's reply presents to choose from, at most three, in the order the reply names them. */
335
+ /** The stores an answer's reply presents to choose from, at most eight, in the order the reply names them. */
213
336
  export interface BuyStores {
214
337
  merchant: string;
215
338
  merchant_name: string;
@@ -236,6 +359,15 @@ export interface BuyPaymentSource {
236
359
  * not know reads as null.
237
360
  */
238
361
  export type BuyNextAction = 'shipping_details' | 'guest_details' | 'store_login' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
362
+ /**
363
+ * A cart the store's checkout is still building after an answer went out, at `store`, since `started_at`: its cart and
364
+ * total reach the shopper as a message of their own (purchase.message `cart_ready`), and only the shopper's yes to that
365
+ * total confirms it.
366
+ */
367
+ export interface BuyCartBuilding {
368
+ store: string;
369
+ started_at: string;
370
+ }
239
371
  /** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
240
372
  export interface BuyTurn {
241
373
  conversation_id: string;
@@ -273,6 +405,8 @@ export interface BuyTurn {
273
405
  stores: BuyStores | null;
274
406
  /** The store this turn's `pick.store_id` named, or an answer such as `the second one` picked; null without one. */
275
407
  store_pick: BuyStorePick | null;
408
+ /** The cart the store's checkout is still building after this answer; null when none is, and on an older API. */
409
+ cart_building: BuyCartBuilding | null;
276
410
  /** The answer as Agentcard sent it. */
277
411
  raw: Record<string, unknown>;
278
412
  }
@@ -307,6 +441,8 @@ export interface BuyConversation {
307
441
  conversation_id: string;
308
442
  status: 'open' | 'closed';
309
443
  turn_in_progress: boolean;
444
+ /** The cart the store's checkout is still building on this conversation; null when none is, and on an older API. */
445
+ cart_building: BuyCartBuilding | null;
310
446
  last_checkout: BuyLastCheckout | null;
311
447
  orders: BuyOrder[];
312
448
  carts: BuyCart[];
@@ -342,6 +478,8 @@ export declare class AgentcardApiError extends Error {
342
478
  }
343
479
  /** A turn as Agentcard answers it. */
344
480
  export declare function turnOf(body: Record<string, unknown>): BuyTurn;
481
+ /** A cart being built, as an answer, a refusal or the conversation read names it. */
482
+ export declare function cartBuildingOf(value: unknown): BuyCartBuilding | null;
345
483
  /** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
346
484
  export declare class AgentcardClient {
347
485
  readonly baseUrl: string;
@@ -354,6 +492,7 @@ export declare class AgentcardClient {
354
492
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
355
493
  private readonly inbox;
356
494
  private readonly deliveredIds;
495
+ private readonly trace;
357
496
  constructor(options: AgentcardClientOptions);
358
497
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
359
498
  get company(): boolean;
@@ -394,6 +533,17 @@ export declare class AgentcardClient {
394
533
  * shopper: the listener and the buy tool share one client in one process.
395
534
  */
396
535
  delivered(externalUserId: string, message: ShopperMessage): void;
536
+ /**
537
+ * Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
538
+ * its `text`, so the shopper never reads that text: `delivered` skips it from now on, and no later
539
+ * `buy.turn` hands it to the model as words the shopper read. `buy.nextStep` calls it.
540
+ */
541
+ answered(externalUserId: string, message: ShopperMessage): void;
542
+ /**
543
+ * Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
544
+ * post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the shopper read.
545
+ */
546
+ unanswered(externalUserId: string, message: ShopperMessage): void;
397
547
  /**
398
548
  * The messages kept for this shopper, oldest first: each one that reached their chat and that no
399
549
  * history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
@@ -409,3 +559,4 @@ export declare class AgentcardClient {
409
559
  private renewAfter;
410
560
  private exchange;
411
561
  }
562
+ export {};
package/dist/client.js CHANGED
@@ -10,6 +10,8 @@
10
10
  */
11
11
  import { isRecord } from './verify.js';
12
12
  import { listenToAgentcard } from './listen.js';
13
+ /** The handlers that ask Agentcard to stream a turn. */
14
+ const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onCart', 'onDelta'];
13
15
  const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
14
16
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
15
17
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -24,6 +26,37 @@ const DELIVERED_PER_SHOPPER = 20;
24
26
  const DELIVERED_SHOPPERS = 10_000;
25
27
  /** The event ids the client remembers taking in, so a message delivered twice reaches the model once. */
26
28
  const DELIVERED_IDS_KEPT = 10_000;
29
+ /** `@opentelemetry/api`, loaded once, only when the app installed it: the package depends on nothing. */
30
+ const OTEL_API = '@opentelemetry/api';
31
+ let otelLoaded = null;
32
+ function loadOtel() {
33
+ otelLoaded ??= import(/* webpackIgnore: true */ /* @vite-ignore */ OTEL_API).then((m) => (m.trace && m.propagation && m.context ? m : m.default?.trace ? m.default : null), () => null);
34
+ return otelLoaded;
35
+ }
36
+ /** The `traceparent` and `tracestate` headers for one call: the active span's, else the app's own, else none. */
37
+ function traceHeaders(otel, own) {
38
+ const carrier = {};
39
+ if (otel) {
40
+ try {
41
+ otel.propagation.inject(otel.context.active(), carrier);
42
+ }
43
+ catch { /* A broken propagator never stops a purchase. */ }
44
+ if (str(carrier.traceparent))
45
+ return pickTrace(carrier);
46
+ }
47
+ let given;
48
+ try {
49
+ given = own?.();
50
+ }
51
+ catch { /* An app's own trace lookup that throws sends no trace. */ }
52
+ const ctx = typeof given === 'string' ? { traceparent: given } : given;
53
+ // Only a well-formed W3C traceparent goes out: anything else would break the header or the backend's parse.
54
+ return isRecord(ctx) && TRACEPARENT.test(String(ctx.traceparent)) ? pickTrace(ctx) : {};
55
+ }
56
+ const TRACEPARENT = /^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$/;
57
+ // A tracestate is printable ASCII of at most 512 characters (W3C); any other value is left off, so it can never stop a purchase.
58
+ const TRACESTATE = /^[\x20-\x7e]{1,512}$/;
59
+ const pickTrace = (c) => typeof c.tracestate === 'string' && TRACESTATE.test(c.tracestate) ? { traceparent: c.traceparent, tracestate: c.tracestate } : { traceparent: c.traceparent };
27
60
  const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'store_login', 'add_card', 'confirm', 'verification_code', 'approve']);
28
61
  /**
29
62
  * A refusal, or a failure to reach Agentcard. `code` is the API's own
@@ -76,6 +109,30 @@ function pageUrl(v) {
76
109
  return null;
77
110
  }
78
111
  }
112
+ /** The most photos one product carries. */
113
+ const IMAGES_MAX = 8;
114
+ /**
115
+ * A product's photos: `main` first, then each https address in `list`, in its order, each once, at most
116
+ * IMAGES_MAX. A value that is no https address, or that names a user before its host, is left out.
117
+ */
118
+ function imagesOf(main, list) {
119
+ // The main photo passes the same check as the others: an https address without credentials. It stays as `imageUrl` spells
120
+ // it, so the list still starts with that address, and a later copy of it is skipped by its normalized form.
121
+ const mainPage = pageUrl(main);
122
+ const ok = !!main && !!mainPage && mainPage.startsWith('https:');
123
+ const out = ok ? [main] : [];
124
+ const seen = new Set(ok ? [mainPage] : []);
125
+ for (const v of Array.isArray(list) ? list : []) {
126
+ if (out.length >= IMAGES_MAX)
127
+ break;
128
+ const page = pageUrl(v);
129
+ if (page && page.startsWith('https:') && !seen.has(page)) {
130
+ seen.add(page);
131
+ out.push(page);
132
+ }
133
+ }
134
+ return out;
135
+ }
79
136
  function storeOf(v) {
80
137
  if (!isRecord(v) || !str(v.id))
81
138
  return null;
@@ -87,6 +144,13 @@ function feeLinesOf(v) {
87
144
  const lines = v.filter(isRecord).flatMap((f) => (str(f.label) && num(f.amountCents) !== null ? [{ label: f.label, amountCents: f.amountCents }] : []));
88
145
  return lines.length ? lines : null;
89
146
  }
147
+ function feesOf(v) {
148
+ const amount = num(v.serviceFeesCents);
149
+ const status = v.merchantFeesPending === true || v.serviceFeesStatus === 'pending' ? 'pending'
150
+ : (v.serviceFeesStatus === undefined || v.serviceFeesStatus === 'known')
151
+ && amount !== null && Number.isSafeInteger(amount) && amount >= 0 ? 'known' : 'unknown';
152
+ return { serviceFeesCents: status === 'known' ? amount : null, serviceFeesStatus: status };
153
+ }
90
154
  function cartOf(v) {
91
155
  if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
92
156
  return null;
@@ -94,15 +158,24 @@ function cartOf(v) {
94
158
  merchant: v.merchant,
95
159
  merchant_name: str(v.merchant_name) ?? v.merchant,
96
160
  store: storeOf(v.store),
97
- items: Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => ({
98
- name: str(i.name) ?? '',
99
- qty: num(i.qty) ?? 1,
100
- ...(num(i.priceCents) !== null ? { priceCents: i.priceCents } : {}),
101
- product_id: str(i.product_id),
102
- url: pageUrl(i.url),
103
- })) : [],
161
+ items: Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => {
162
+ const qty = num(i.qty) ?? 1;
163
+ const price = num(i.priceCents);
164
+ return {
165
+ name: str(i.name) ?? '',
166
+ qty,
167
+ ...(price !== null ? { priceCents: price } : {}),
168
+ // An older API sends no line total: the line's price times its quantity is it.
169
+ lineTotalCents: num(i.lineTotalCents) ?? (price !== null ? price * qty : null),
170
+ options: Array.isArray(i.options) ? i.options.filter((o) => typeof o === 'string' && o.trim() !== '') : [],
171
+ variant: str(i.variant),
172
+ product_id: str(i.product_id),
173
+ ref: str(i.ref),
174
+ url: pageUrl(i.url),
175
+ };
176
+ }) : [],
104
177
  subtotalCents: num(v.subtotalCents),
105
- serviceFeesCents: num(v.serviceFeesCents) ?? 0,
178
+ ...feesOf(v),
106
179
  merchantFeeLines: feeLinesOf(v.merchantFeeLines),
107
180
  tipCents: num(v.tipCents) ?? 0,
108
181
  totalCents: num(v.totalCents) ?? 0,
@@ -131,21 +204,31 @@ function flightOf(value) {
131
204
  function catalogOf(v) {
132
205
  if (!isRecord(v))
133
206
  return null;
134
- const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => {
207
+ const items = catalogItemsOf(v.items);
208
+ return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
209
+ }
210
+ /** A catalog block's products, as `items` or `more` lists them. */
211
+ function catalogItemsOf(v) {
212
+ return Array.isArray(v) ? v.filter(isRecord).map((i) => {
135
213
  const flight = flightOf(i.flight);
136
214
  return {
137
215
  id: str(i.id) ?? '',
138
216
  ref: str(i.ref),
139
217
  name: str(i.name),
140
218
  priceCents: num(i.priceCents),
219
+ ...(i.price_from === true && num(i.priceCents) !== null ? { price_from: true } : {}),
141
220
  currency: str(i.currency),
142
221
  image_url: str(i.image_url),
222
+ image_urls: imagesOf(str(i.image_url), i.image_urls),
143
223
  ...(flight ? { flight } : {}),
144
224
  store: storeOf(i.store),
145
225
  url: pageUrl(i.url),
146
226
  };
147
227
  }) : [];
148
- return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
228
+ }
229
+ /** A count Agentcard sent: a whole number, never negative. Null otherwise. */
230
+ function countOf(v) {
231
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
149
232
  }
150
233
  /** The stores an answer presents: each one with an id and a name, and no block when none has both. */
151
234
  function storesOf(v) {
@@ -194,15 +277,21 @@ export function turnOf(body) {
194
277
  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,
195
278
  stores: storesOf(body.stores),
196
279
  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,
280
+ cart_building: cartBuildingOf(body.cart_building),
197
281
  raw: body,
198
282
  };
199
283
  }
284
+ /** A cart being built, as an answer, a refusal or the conversation read names it. */
285
+ export function cartBuildingOf(value) {
286
+ return isRecord(value) && str(value.store) ? { store: value.store, started_at: str(value.started_at) ?? '' } : null;
287
+ }
200
288
  function conversationOf(body) {
201
289
  const last = isRecord(body.last_checkout) ? body.last_checkout : null;
202
290
  return {
203
291
  conversation_id: str(body.conversation_id) ?? '',
204
292
  status: body.status === 'closed' ? 'closed' : 'open',
205
293
  turn_in_progress: body.turn_in_progress === true,
294
+ cart_building: cartBuildingOf(body.cart_building),
206
295
  last_checkout: last ? {
207
296
  at: str(last.at) ?? '',
208
297
  merchant: str(last.merchant) ?? '',
@@ -335,14 +424,21 @@ async function eachEvent(res, onEvent) {
335
424
  }
336
425
  /**
337
426
  * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
338
- * status in `http_status`. Each `progress` and `catalog` goes to its handler on the way. A stream that
339
- * breaks or ends before its envelope, once it named the conversation, reads as a turn past its budget
427
+ * status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment` and `cart` goes to its handler
428
+ * on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
429
+ * ends before its envelope, once it named the conversation, reads as a turn past its budget
340
430
  * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
341
431
  */
342
432
  async function readStreamedTurn(res, stream, timeoutMs) {
343
433
  let envelope = null;
344
434
  let conversationId = null;
345
- const delivering = [];
435
+ // The catalog, segment, cart and delta handlers run one after another, in the order their events arrived, so an app
436
+ // never shows a message above the cards it names, nor a piece of the next message before the message ahead of it. The
437
+ // stream is read on meanwhile, and the answer waits for the last one. A handler that throws costs only itself.
438
+ let delivering = Promise.resolve();
439
+ const deliver = (hand) => {
440
+ delivering = delivering.then(() => hand()).catch(() => { });
441
+ };
346
442
  let failure = null;
347
443
  try {
348
444
  await eachEvent(res, (event, text) => {
@@ -350,6 +446,8 @@ async function readStreamedTurn(res, stream, timeoutMs) {
350
446
  if (!data)
351
447
  return;
352
448
  conversationId = str(data.conversation_id) ?? conversationId;
449
+ // The conversation this event belongs to: its own, or the one an earlier event of the same turn named.
450
+ const named = conversationId;
353
451
  if (event === 'envelope') {
354
452
  envelope = data;
355
453
  return true;
@@ -362,19 +460,59 @@ async function readStreamedTurn(res, stream, timeoutMs) {
362
460
  // The app's display failing never fails the turn.
363
461
  }
364
462
  }
365
- else if (event === 'catalog' && stream.onCatalog && str(data.conversation_id)) {
463
+ else if (event === 'status' && typeof data.text === 'string' && data.text.trim() !== '' && typeof data.step === 'string' && stream.onStatus) {
464
+ try {
465
+ stream.onStatus({ conversationId: named, step: data.step, text: data.text });
466
+ }
467
+ catch {
468
+ // The app's display failing never fails the turn.
469
+ }
470
+ }
471
+ else if (event === 'delta' && typeof data.text === 'string' && data.text !== '' && stream.onDelta) {
472
+ deliver(() => stream.onDelta({ conversationId: named, text: data.text }));
473
+ }
474
+ else if (event === 'catalog' && (stream.onCatalog || stream.onFound) && str(data.conversation_id)) {
366
475
  const catalog = catalogOf(data.catalog);
367
476
  if (!catalog || catalog.items.length === 0)
368
477
  return;
369
- const asOf = isRecord(data.catalog) ? str(data.catalog.as_of) : null;
370
- delivering.push(Promise.resolve().then(() => stream.onCatalog({ conversationId: data.conversation_id, catalog, asOf })).catch(() => { }));
478
+ const block = data.catalog;
479
+ const asOf = str(block.as_of);
480
+ const shown = countOf(block.shown);
481
+ const found = countOf(block.found);
482
+ const update = { conversationId: data.conversation_id, catalog, asOf, shown, found, more: catalogItemsOf(block.more) };
483
+ // The count follows the cards it counts, so an app never shows "8 of 23" above no cards, and it settles before the
484
+ // next event, so a later count never lands before an earlier one. A card display that throws never costs the count.
485
+ deliver(async () => {
486
+ try {
487
+ await stream.onCatalog?.(update);
488
+ }
489
+ catch {
490
+ // The app's display failing never fails the turn.
491
+ }
492
+ if (shown !== null && found !== null && found >= shown)
493
+ await stream.onFound?.({ conversationId: update.conversationId, shown, found });
494
+ });
495
+ }
496
+ else if (event === 'find' && stream.onFound && str(data.conversation_id)) {
497
+ // Find mode's count from the product index: the cards it shows of the products it found.
498
+ const shown = countOf(data.shown);
499
+ const found = countOf(data.found);
500
+ if (shown !== null && found !== null && found >= shown)
501
+ deliver(() => stream.onFound({ conversationId: data.conversation_id, shown, found }));
502
+ }
503
+ else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
504
+ deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
505
+ }
506
+ else if (event === 'cart' && stream.onCart && str(data.conversation_id)) {
507
+ const update = { conversationId: data.conversation_id, cart: cartOf(data.cart), carts: cartsOf(data.carts) };
508
+ deliver(() => stream.onCart(update));
371
509
  }
372
510
  });
373
511
  }
374
512
  catch (error) {
375
513
  failure = error;
376
514
  }
377
- await Promise.all(delivering);
515
+ await delivering;
378
516
  const answered = envelope;
379
517
  if (answered) {
380
518
  const { http_status: httpStatus, ...body } = answered;
@@ -398,6 +536,7 @@ export class AgentcardClient {
398
536
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
399
537
  inbox = new Map();
400
538
  deliveredIds = new Set();
539
+ trace;
401
540
  constructor(options) {
402
541
  const o = (options ?? {});
403
542
  const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
@@ -416,6 +555,7 @@ export class AgentcardClient {
416
555
  this.baseUrl = (str(o.baseUrl) ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
417
556
  this.credentials = hasCredentials ? { clientId: o.clientId, clientSecret: o.clientSecret } : null;
418
557
  this.shopperToken = hasCredentials ? null : o.accessToken;
558
+ this.trace = isRecord(o.trace) ? o.trace : {};
419
559
  }
420
560
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
421
561
  get company() {
@@ -450,10 +590,39 @@ export class AgentcardClient {
450
590
  if (!isRecord(body))
451
591
  throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
452
592
  this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
453
- const streamed = !!stream && (typeof stream.onProgress === 'function' || typeof stream.onCatalog === 'function');
454
- // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
455
- const path = streamed ? '/buy?stream=1&soft_timeout=1' : '/buy';
456
- return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined));
593
+ const streamed = !!stream && STREAM_HANDLERS.some((name) => typeof stream[name] === 'function');
594
+ // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too. The pieces of
595
+ // each message go out only to a caller that asks for them.
596
+ const path = !streamed ? '/buy' : typeof stream.onDelta === 'function' ? '/buy?stream=deltas&soft_timeout=1' : '/buy?stream=1&soft_timeout=1';
597
+ const otel = this.trace.otel === false ? null : this.trace.otel ?? await loadOtel();
598
+ const run = async () => {
599
+ const headers = traceHeaders(otel, this.trace.traceContext);
600
+ return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined, headers));
601
+ };
602
+ if (!otel)
603
+ return run();
604
+ // One `sdk.buy` span per call, a client span (kind 2) the backend's `buy.request` hangs under. It names the
605
+ // conversation and how the call ended, never what the shopper said.
606
+ const attributes = { 'agentcard.streamed': streamed };
607
+ const conversationId = str(body.conversation_id);
608
+ if (conversationId)
609
+ attributes['agentcard.conversation_id'] = conversationId;
610
+ return otel.trace.getTracer('commerce-sdk').startActiveSpan('sdk.buy', { kind: 2, attributes }, async (span) => {
611
+ try {
612
+ const turn = await run();
613
+ if (turn.conversation_id)
614
+ span.setAttribute('agentcard.conversation_id', turn.conversation_id);
615
+ span.setAttribute('agentcard.buy_status', turn.status);
616
+ return turn;
617
+ }
618
+ catch (error) {
619
+ span.setStatus({ code: 2, message: error instanceof AgentcardApiError ? error.code : 'error' });
620
+ throw error;
621
+ }
622
+ finally {
623
+ span.end();
624
+ }
625
+ });
457
626
  }
458
627
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
459
628
  async conversation(conversationId, who = {}) {
@@ -516,6 +685,37 @@ export class AgentcardClient {
516
685
  if (this.inbox.size > DELIVERED_SHOPPERS)
517
686
  this.inbox.delete(this.inbox.keys().next().value);
518
687
  }
688
+ /**
689
+ * Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
690
+ * its `text`, so the shopper never reads that text: `delivered` skips it from now on, and no later
691
+ * `buy.turn` hands it to the model as words the shopper read. `buy.nextStep` calls it.
692
+ */
693
+ answered(externalUserId, message) {
694
+ if (!str(externalUserId))
695
+ throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
696
+ if (!isRecord(message))
697
+ throw new TypeError('Pass the message onMessage got.');
698
+ if (str(message.eventId)) {
699
+ this.deliveredIds.add(message.eventId);
700
+ if (this.deliveredIds.size > DELIVERED_IDS_KEPT)
701
+ this.deliveredIds.delete(this.deliveredIds.values().next().value);
702
+ }
703
+ const kept = this.inbox.get(externalUserId);
704
+ if (kept)
705
+ this.forgetMessages(externalUserId, kept.filter((m) => m === message || (!!message.eventId && m.eventId === message.eventId)));
706
+ }
707
+ /**
708
+ * Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
709
+ * post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the shopper read.
710
+ */
711
+ unanswered(externalUserId, message) {
712
+ if (!str(externalUserId))
713
+ throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
714
+ if (!isRecord(message))
715
+ throw new TypeError('Pass the message onMessage got.');
716
+ if (str(message.eventId))
717
+ this.deliveredIds.delete(message.eventId);
718
+ }
519
719
  /**
520
720
  * The messages kept for this shopper, oldest first: each one that reached their chat and that no
521
721
  * history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
@@ -546,10 +746,10 @@ export class AgentcardClient {
546
746
  throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
547
747
  }
548
748
  }
549
- async call(method, path, body, timeoutMs, stream) {
749
+ async call(method, path, body, timeoutMs, stream, extra = {}) {
550
750
  let token = await this.bearer();
551
751
  for (let attempt = 0;; attempt++) {
552
- const answer = await this.send(method, path, token, body, timeoutMs, stream);
752
+ const answer = await this.send(method, path, token, body, timeoutMs, stream, extra);
553
753
  if (answer.status === 401 && attempt === 0 && this.credentials) {
554
754
  token = await this.renewAfter(token);
555
755
  continue;
@@ -567,12 +767,13 @@ export class AgentcardClient {
567
767
  throw errorOf(answer.status, answer.body);
568
768
  }
569
769
  }
570
- async send(method, path, token, body, timeoutMs, stream) {
770
+ async send(method, path, token, body, timeoutMs, stream, extra = {}) {
571
771
  let res;
572
772
  try {
573
773
  res = await this.fetchFn(`${this.baseUrl}${path}`, {
574
774
  method,
575
775
  headers: {
776
+ ...extra,
576
777
  authorization: `Bearer ${token}`,
577
778
  accept: stream ? 'text/event-stream, application/json' : 'application/json',
578
779
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),