@agent-cards/commerce-sdk 0.13.0 → 0.15.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,144 @@ 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
+ /**
55
+ * Whether a place a card shows has room at the time the shopper asked for: `checking` while Agentcard's live search runs,
56
+ * `available` once it found the place open, with the open times it named in `slots` (empty when it named none),
57
+ * `unavailable` when it found the place full then, and `unknown` when the search ended without saying either way. Only
58
+ * `available` is a place to offer as a choice. A product with no such question, such as a retail product, carries none.
59
+ */
60
+ export type BuyAvailability = {
61
+ status: 'checking';
62
+ } | {
63
+ status: 'available';
64
+ slots: string[];
65
+ } | {
66
+ status: 'unavailable';
67
+ } | {
68
+ status: 'unknown';
69
+ };
70
+ /** What Agentcard's live search said of the places a running turn's cards show, matched to each card by its catalog `id`. */
71
+ export interface BuyAvailabilityUpdate {
72
+ conversationId: string;
73
+ items: Array<{
74
+ id: string;
75
+ availability: BuyAvailability;
76
+ }>;
77
+ }
78
+ /** How many products a running turn found so far, and how many of them it shows: "8 of 23 shown". */
79
+ export interface BuyFoundUpdate {
80
+ conversationId: string;
81
+ shown: number;
82
+ found: number;
83
+ }
84
+ /**
85
+ * 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
86
+ * `searching Target and Walmart` or `adding it to your cart`, and `step` names the step for an app that writes its own,
87
+ * such as `searching`, `adding_to_cart` or `store_checkout`. No status comes for a turn that only writes its reply.
88
+ */
89
+ export interface BuyTurnStatus {
90
+ conversationId: string | null;
91
+ step: string;
92
+ text: string;
93
+ }
94
+ /** One message Agentcard finished writing while the turn runs, links included, as the answer's `messages` then holds it. */
95
+ export interface BuySegmentUpdate {
96
+ conversationId: string | null;
97
+ text: string;
98
+ }
99
+ /** The carts on the table the moment a running turn builds or changes one, before the message that announces it. */
100
+ export interface BuyCartUpdate {
101
+ conversationId: string;
102
+ /** The cart a confirm would place, as the answer's `cart` names it, or null when none would be. */
103
+ cart: BuyCart | null;
104
+ /** Every cart on the table, oldest first, as the answer's `carts`. */
105
+ carts: BuyCart[];
106
+ }
107
+ /**
108
+ * A piece of a message while Agentcard writes it. A piece can hold words Agentcard drops before the message is done, such
109
+ * as a line that only says it is looking, so show the pieces as a draft and replace them with each `onSegment` message.
110
+ */
111
+ export interface BuyDeltaUpdate {
112
+ conversationId: string | null;
113
+ text: string;
114
+ }
115
+ /**
116
+ * Called while a purchase turn runs. Passing any one asks Agentcard to stream the turn. `onCatalog`, `onSegment`,
117
+ * `onCart` and `onDelta` hear their events one at a time, in the order Agentcard sent them, each once the one before it
118
+ * settled, so a piece of the next message never reaches the app before the message ahead of it. The client waits for
119
+ * the last of them before it hands back the turn's answer.
120
+ */
49
121
  export interface BuyStreamHandlers {
50
122
  onProgress?: (progress: BuyProgress) => void;
51
- /** The client waits for what this returns before it hands back the turn's answer. */
123
+ /** Each step the turn starts, as a line to show while the shopper waits. */
124
+ onStatus?: (status: BuyTurnStatus) => void;
125
+ /** How many products the turn found, each time the count grows. */
126
+ onFound?: (update: BuyFoundUpdate) => void | Promise<void>;
52
127
  onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
128
+ /** Each message Agentcard writes, the moment it is written. */
129
+ onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
130
+ /** The carts, each time the turn builds or changes one. */
131
+ onCart?: (update: BuyCartUpdate) => void | Promise<void>;
132
+ /** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
133
+ onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
134
+ /** Whether the places the turn's cards show have room, once Agentcard's live search says, after the cards it changes. */
135
+ onAvailability?: (update: BuyAvailabilityUpdate) => void | Promise<void>;
53
136
  }
54
137
  export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
55
138
  /** 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
139
  export declare const BUY_TURN_TIMEOUT_MS = 130000;
140
+ /** The W3C trace context a `POST /buy` carries, so the backend's spans join the caller's trace. */
141
+ export interface TraceContext {
142
+ traceparent: string;
143
+ tracestate?: string;
144
+ }
145
+ /** The part of `@opentelemetry/api` the client calls: pass the module itself as `otel`. */
146
+ export interface OtelApi {
147
+ trace: {
148
+ getTracer(name: string): OtelTracer;
149
+ };
150
+ context: {
151
+ active(): unknown;
152
+ };
153
+ propagation: {
154
+ inject(context: unknown, carrier: Record<string, string>): void;
155
+ };
156
+ }
157
+ interface OtelTracer {
158
+ startActiveSpan<T>(name: string, options: {
159
+ kind?: number;
160
+ attributes?: Record<string, string | boolean>;
161
+ }, fn: (span: OtelSpan) => T): T;
162
+ }
163
+ interface OtelSpan {
164
+ setAttribute(key: string, value: string | number | boolean): unknown;
165
+ setStatus(status: {
166
+ code: number;
167
+ message?: string;
168
+ }): unknown;
169
+ end(): void;
170
+ }
171
+ /**
172
+ * How the client finds the trace a `POST /buy` belongs to. `otel` is `@opentelemetry/api`: by default the client
173
+ * loads it when the app installed it, and `false` turns that off. `traceContext` names the trace by hand, for an app
174
+ * without OpenTelemetry: return its `traceparent`, or nothing to send none. The active OpenTelemetry context wins.
175
+ */
176
+ export interface AgentcardTraceOptions {
177
+ otel?: OtelApi | false;
178
+ traceContext?: () => TraceContext | string | null | undefined;
179
+ }
57
180
  /** A company's client credentials, or one shopper's own access token. */
58
181
  export type AgentcardClientOptions = {
59
182
  baseUrl?: string;
60
183
  fetch?: FetchLike;
184
+ trace?: AgentcardTraceOptions;
61
185
  } & ({
62
186
  clientId: string;
63
187
  clientSecret: string;
@@ -155,7 +279,8 @@ export interface BuyCart {
155
279
  items: BuyCartLine[];
156
280
  /** The items alone, or null from an older API. */
157
281
  subtotalCents: number | null;
158
- serviceFeesCents: number;
282
+ serviceFeesCents: number | null;
283
+ serviceFeesStatus?: 'known' | 'pending' | 'unknown';
159
284
  /** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
160
285
  merchantFeeLines: BuyCartFeeLine[] | null;
161
286
  tipCents: number;
@@ -202,6 +327,8 @@ export interface BuyCatalogItem {
202
327
  store: BuyStore | null;
203
328
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
204
329
  url: string | null;
330
+ /** Whether a place has room at the asked time (BuyAvailability). Absent on a product with no such question. */
331
+ availability?: BuyAvailability;
205
332
  }
206
333
  /**
207
334
  * The products the last search found. `presented` is true when this answer's reply presents them: the
@@ -393,6 +520,7 @@ export declare class AgentcardClient {
393
520
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
394
521
  private readonly inbox;
395
522
  private readonly deliveredIds;
523
+ private readonly trace;
396
524
  constructor(options: AgentcardClientOptions);
397
525
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
398
526
  get company(): boolean;
@@ -459,3 +587,4 @@ export declare class AgentcardClient {
459
587
  private renewAfter;
460
588
  private exchange;
461
589
  }
590
+ 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', 'onAvailability'];
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
@@ -111,6 +144,13 @@ function feeLinesOf(v) {
111
144
  const lines = v.filter(isRecord).flatMap((f) => (str(f.label) && num(f.amountCents) !== null ? [{ label: f.label, amountCents: f.amountCents }] : []));
112
145
  return lines.length ? lines : null;
113
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
+ }
114
154
  function cartOf(v) {
115
155
  if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
116
156
  return null;
@@ -135,7 +175,7 @@ function cartOf(v) {
135
175
  };
136
176
  }) : [],
137
177
  subtotalCents: num(v.subtotalCents),
138
- serviceFeesCents: num(v.serviceFeesCents) ?? 0,
178
+ ...feesOf(v),
139
179
  merchantFeeLines: feeLinesOf(v.merchantFeeLines),
140
180
  tipCents: num(v.tipCents) ?? 0,
141
181
  totalCents: num(v.totalCents) ?? 0,
@@ -164,7 +204,12 @@ function flightOf(value) {
164
204
  function catalogOf(v) {
165
205
  if (!isRecord(v))
166
206
  return null;
167
- 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) => {
168
213
  const flight = flightOf(i.flight);
169
214
  return {
170
215
  id: str(i.id) ?? '',
@@ -178,9 +223,33 @@ function catalogOf(v) {
178
223
  ...(flight ? { flight } : {}),
179
224
  store: storeOf(i.store),
180
225
  url: pageUrl(i.url),
226
+ ...availabilityField(i),
181
227
  };
182
228
  }) : [];
183
- return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
229
+ }
230
+ /**
231
+ * A place's availability as Agentcard marks it: `checking`, `open` or `available` with the times in `slots`, `none` or
232
+ * `unavailable`, and `unknown`. A mark this version does not know reads as `unknown`, so no place is offered on a guess.
233
+ */
234
+ function availabilityOf(v) {
235
+ const mark = v.availability;
236
+ if (mark === 'checking')
237
+ return { status: 'checking' };
238
+ if (mark === 'open' || mark === 'available') {
239
+ const slots = Array.isArray(v.slots) ? v.slots.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
240
+ return { status: 'available', slots };
241
+ }
242
+ if (mark === 'none' || mark === 'unavailable')
243
+ return { status: 'unavailable' };
244
+ return typeof mark === 'string' ? { status: 'unknown' } : null;
245
+ }
246
+ function availabilityField(v) {
247
+ const availability = availabilityOf(v);
248
+ return availability ? { availability } : {};
249
+ }
250
+ /** A count Agentcard sent: a whole number, never negative. Null otherwise. */
251
+ function countOf(v) {
252
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
184
253
  }
185
254
  /** The stores an answer presents: each one with an id and a name, and no block when none has both. */
186
255
  function storesOf(v) {
@@ -376,14 +445,21 @@ async function eachEvent(res, onEvent) {
376
445
  }
377
446
  /**
378
447
  * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
379
- * status in `http_status`. Each `progress` and `catalog` goes to its handler on the way. A stream that
380
- * breaks or ends before its envelope, once it named the conversation, reads as a turn past its budget
448
+ * status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `cart` and `venue_availability` goes to its handler
449
+ * on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
450
+ * ends before its envelope, once it named the conversation, reads as a turn past its budget
381
451
  * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
382
452
  */
383
453
  async function readStreamedTurn(res, stream, timeoutMs) {
384
454
  let envelope = null;
385
455
  let conversationId = null;
386
- const delivering = [];
456
+ // The catalog, segment, cart and delta handlers run one after another, in the order their events arrived, so an app
457
+ // never shows a message above the cards it names, nor a piece of the next message before the message ahead of it. The
458
+ // stream is read on meanwhile, and the answer waits for the last one. A handler that throws costs only itself.
459
+ let delivering = Promise.resolve();
460
+ const deliver = (hand) => {
461
+ delivering = delivering.then(() => hand()).catch(() => { });
462
+ };
387
463
  let failure = null;
388
464
  try {
389
465
  await eachEvent(res, (event, text) => {
@@ -391,6 +467,8 @@ async function readStreamedTurn(res, stream, timeoutMs) {
391
467
  if (!data)
392
468
  return;
393
469
  conversationId = str(data.conversation_id) ?? conversationId;
470
+ // The conversation this event belongs to: its own, or the one an earlier event of the same turn named.
471
+ const named = conversationId;
394
472
  if (event === 'envelope') {
395
473
  envelope = data;
396
474
  return true;
@@ -403,19 +481,68 @@ async function readStreamedTurn(res, stream, timeoutMs) {
403
481
  // The app's display failing never fails the turn.
404
482
  }
405
483
  }
406
- else if (event === 'catalog' && stream.onCatalog && str(data.conversation_id)) {
484
+ else if (event === 'status' && typeof data.text === 'string' && data.text.trim() !== '' && typeof data.step === 'string' && stream.onStatus) {
485
+ try {
486
+ stream.onStatus({ conversationId: named, step: data.step, text: data.text });
487
+ }
488
+ catch {
489
+ // The app's display failing never fails the turn.
490
+ }
491
+ }
492
+ else if (event === 'delta' && typeof data.text === 'string' && data.text !== '' && stream.onDelta) {
493
+ deliver(() => stream.onDelta({ conversationId: named, text: data.text }));
494
+ }
495
+ else if (event === 'catalog' && (stream.onCatalog || stream.onFound) && str(data.conversation_id)) {
407
496
  const catalog = catalogOf(data.catalog);
408
497
  if (!catalog || catalog.items.length === 0)
409
498
  return;
410
- const asOf = isRecord(data.catalog) ? str(data.catalog.as_of) : null;
411
- delivering.push(Promise.resolve().then(() => stream.onCatalog({ conversationId: data.conversation_id, catalog, asOf })).catch(() => { }));
499
+ const block = data.catalog;
500
+ const asOf = str(block.as_of);
501
+ const shown = countOf(block.shown);
502
+ const found = countOf(block.found);
503
+ const update = { conversationId: data.conversation_id, catalog, asOf, shown, found, more: catalogItemsOf(block.more) };
504
+ // The count follows the cards it counts, so an app never shows "8 of 23" above no cards, and it settles before the
505
+ // next event, so a later count never lands before an earlier one. A card display that throws never costs the count.
506
+ deliver(async () => {
507
+ try {
508
+ await stream.onCatalog?.(update);
509
+ }
510
+ catch {
511
+ // The app's display failing never fails the turn.
512
+ }
513
+ if (shown !== null && found !== null && found >= shown)
514
+ await stream.onFound?.({ conversationId: update.conversationId, shown, found });
515
+ });
516
+ }
517
+ else if (event === 'find' && stream.onFound && str(data.conversation_id)) {
518
+ // Find mode's count from the product index: the cards it shows of the products it found.
519
+ const shown = countOf(data.shown);
520
+ const found = countOf(data.found);
521
+ if (shown !== null && found !== null && found >= shown)
522
+ deliver(() => stream.onFound({ conversationId: data.conversation_id, shown, found }));
523
+ }
524
+ else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
525
+ deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
526
+ }
527
+ else if (event === 'venue_availability' && stream.onAvailability && str(data.conversation_id) && Array.isArray(data.items)) {
528
+ const items = data.items.filter(isRecord).flatMap((i) => {
529
+ const id = str(i.id);
530
+ const availability = availabilityOf(i);
531
+ return id && availability ? [{ id, availability }] : [];
532
+ });
533
+ if (items.length)
534
+ deliver(() => stream.onAvailability({ conversationId: data.conversation_id, items }));
535
+ }
536
+ else if (event === 'cart' && stream.onCart && str(data.conversation_id)) {
537
+ const update = { conversationId: data.conversation_id, cart: cartOf(data.cart), carts: cartsOf(data.carts) };
538
+ deliver(() => stream.onCart(update));
412
539
  }
413
540
  });
414
541
  }
415
542
  catch (error) {
416
543
  failure = error;
417
544
  }
418
- await Promise.all(delivering);
545
+ await delivering;
419
546
  const answered = envelope;
420
547
  if (answered) {
421
548
  const { http_status: httpStatus, ...body } = answered;
@@ -439,6 +566,7 @@ export class AgentcardClient {
439
566
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
440
567
  inbox = new Map();
441
568
  deliveredIds = new Set();
569
+ trace;
442
570
  constructor(options) {
443
571
  const o = (options ?? {});
444
572
  const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
@@ -457,6 +585,7 @@ export class AgentcardClient {
457
585
  this.baseUrl = (str(o.baseUrl) ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
458
586
  this.credentials = hasCredentials ? { clientId: o.clientId, clientSecret: o.clientSecret } : null;
459
587
  this.shopperToken = hasCredentials ? null : o.accessToken;
588
+ this.trace = isRecord(o.trace) ? o.trace : {};
460
589
  }
461
590
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
462
591
  get company() {
@@ -491,10 +620,39 @@ export class AgentcardClient {
491
620
  if (!isRecord(body))
492
621
  throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
493
622
  this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
494
- const streamed = !!stream && (typeof stream.onProgress === 'function' || typeof stream.onCatalog === 'function');
495
- // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
496
- const path = streamed ? '/buy?stream=1&soft_timeout=1' : '/buy';
497
- return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined));
623
+ const streamed = !!stream && STREAM_HANDLERS.some((name) => typeof stream[name] === 'function');
624
+ // A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too. The pieces of
625
+ // each message go out only to a caller that asks for them.
626
+ const path = !streamed ? '/buy' : typeof stream.onDelta === 'function' ? '/buy?stream=deltas&soft_timeout=1' : '/buy?stream=1&soft_timeout=1';
627
+ const otel = this.trace.otel === false ? null : this.trace.otel ?? await loadOtel();
628
+ const run = async () => {
629
+ const headers = traceHeaders(otel, this.trace.traceContext);
630
+ return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined, headers));
631
+ };
632
+ if (!otel)
633
+ return run();
634
+ // One `sdk.buy` span per call, a client span (kind 2) the backend's `buy.request` hangs under. It names the
635
+ // conversation and how the call ended, never what the shopper said.
636
+ const attributes = { 'agentcard.streamed': streamed };
637
+ const conversationId = str(body.conversation_id);
638
+ if (conversationId)
639
+ attributes['agentcard.conversation_id'] = conversationId;
640
+ return otel.trace.getTracer('commerce-sdk').startActiveSpan('sdk.buy', { kind: 2, attributes }, async (span) => {
641
+ try {
642
+ const turn = await run();
643
+ if (turn.conversation_id)
644
+ span.setAttribute('agentcard.conversation_id', turn.conversation_id);
645
+ span.setAttribute('agentcard.buy_status', turn.status);
646
+ return turn;
647
+ }
648
+ catch (error) {
649
+ span.setStatus({ code: 2, message: error instanceof AgentcardApiError ? error.code : 'error' });
650
+ throw error;
651
+ }
652
+ finally {
653
+ span.end();
654
+ }
655
+ });
498
656
  }
499
657
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
500
658
  async conversation(conversationId, who = {}) {
@@ -618,10 +776,10 @@ export class AgentcardClient {
618
776
  throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
619
777
  }
620
778
  }
621
- async call(method, path, body, timeoutMs, stream) {
779
+ async call(method, path, body, timeoutMs, stream, extra = {}) {
622
780
  let token = await this.bearer();
623
781
  for (let attempt = 0;; attempt++) {
624
- const answer = await this.send(method, path, token, body, timeoutMs, stream);
782
+ const answer = await this.send(method, path, token, body, timeoutMs, stream, extra);
625
783
  if (answer.status === 401 && attempt === 0 && this.credentials) {
626
784
  token = await this.renewAfter(token);
627
785
  continue;
@@ -639,12 +797,13 @@ export class AgentcardClient {
639
797
  throw errorOf(answer.status, answer.body);
640
798
  }
641
799
  }
642
- async send(method, path, token, body, timeoutMs, stream) {
800
+ async send(method, path, token, body, timeoutMs, stream, extra = {}) {
643
801
  let res;
644
802
  try {
645
803
  res = await this.fetchFn(`${this.baseUrl}${path}`, {
646
804
  method,
647
805
  headers: {
806
+ ...extra,
648
807
  authorization: `Bearer ${token}`,
649
808
  accept: stream ? 'text/event-stream, application/json' : 'application/json',
650
809
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
@@ -13,6 +13,7 @@ export const BUY_TOOL_DESCRIPTION = [
13
13
  'When the shopper sends a link to a product, such as a buy.trychannel3.com link, put the whole link in `ask` exactly as they sent it, query string included, with their words beside it: Agentcard buys that exact product from the link, so never describe the link in words instead.',
14
14
  'Tell the shopper what `reply` says, in your own voice, except the products and stores it lists that reach the shopper as cards.',
15
15
  "When `shopper_already_told_looking` is true, the shopper already knows you are working on their request, from a reply or a 👀 on their message, so never acknowledge it again: open with the answer or the question.",
16
+ 'When `shopper_already_read_reply` is true, the shopper already read `reply` as Agentcard wrote it, so never say it again in any words: answer with only what `reply` leaves out, in one short line, or with nothing.',
16
17
  'Show the shopper the cart before you confirm it.',
17
18
  'When `cart_building` is set, the store is still building that cart: tell the shopper in one short line that its total is on its way, never ask them to confirm it, and confirm it only after they say yes to the total in the message Agentcard sends next.',
18
19
  '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.',
@@ -0,0 +1,50 @@
1
+ import type { BuyNextAction } from './client.js';
2
+ import type { BuyToolInput } from './definition.js';
3
+ /** Why a message goes straight to Agentcard. */
4
+ export type DirectReason = 'product_ask' | 'pick' | 'answer' | 'confirm';
5
+ /** The buy call for a message that goes straight to Agentcard, and why it does. */
6
+ export interface DirectCall {
7
+ input: BuyToolInput;
8
+ reason: DirectReason;
9
+ }
10
+ /** What Agentcard last told the shopper, which decides whether a pick or an answer goes straight. */
11
+ export interface DirectContext {
12
+ /**
13
+ * True when the shopper's last turn ended on an answer from Agentcard: its last call returned, and
14
+ * no turn since ran without one. False or left out, only a request to buy goes straight.
15
+ */
16
+ answered?: boolean;
17
+ /** The step Agentcard's last answer asked for, its `next_action`. */
18
+ nextAction?: BuyNextAction | null;
19
+ /** Agentcard's last reply, the question a short answer answers. */
20
+ asked?: string | null;
21
+ /** The products Agentcard's last answer showed, in order, with the `product_id` a pick sends. */
22
+ products?: ReadonlyArray<{
23
+ productId: string | null;
24
+ name: string;
25
+ }>;
26
+ /** The stores Agentcard's last answer showed, with the `store_id` a pick sends. */
27
+ stores?: ReadonlyArray<{
28
+ storeId: string;
29
+ name: string;
30
+ }>;
31
+ }
32
+ /**
33
+ * True when the message asks to buy or book something, read with no context: a shopping verb with what
34
+ * to get, a product link, a product at a named store, or a table. A question, small talk, a pick or an
35
+ * answer, a command to the cart, the shopper's own details and anything about the account read false.
36
+ */
37
+ export declare function isProductAsk(text: string): boolean;
38
+ /**
39
+ * The call a shopper's message makes straight to Agentcard, with no model deciding it, or null when
40
+ * your model decides: see the top of this file for what goes straight. `ask` carries the shopper's
41
+ * words as they sent them, a confirm goes in only for a plain yes to the total Agentcard asked them to
42
+ * confirm, and a pick names its `product_id` or `store_id` only when the message names one exactly.
43
+ *
44
+ * ```ts
45
+ * directCall('find me usb-c cables under $20'); // { input: { ask: 'find me usb-c cables under $20', confirm: false, ... }, reason: 'product_ask' }
46
+ * directCall('what time is it'); // null
47
+ * directCall('yes', { answered: true, nextAction: 'confirm' }); // { input: { ask: 'yes', confirm: true, ... }, reason: 'confirm' }
48
+ * ```
49
+ */
50
+ export declare function directCall(shopperText: string, context?: DirectContext): DirectCall | null;