@agent-cards/commerce-sdk 0.13.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/buy-tool.js CHANGED
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
35
35
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
36
+ import { directCall } from './direct.js';
36
37
  import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
37
38
  import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
38
39
  import { isStale } from './shopper-message.js';
@@ -197,6 +198,11 @@ export function productsOf(turn) {
197
198
  return turn.catalog ? productsOfCatalog(turn.catalog) : [];
198
199
  }
199
200
  /** The products of a search, as the tool reads them. */
201
+ /** A product's card as `sendLink` hears it: the id the model reads and a pick takes, never the store page, which is `url`. A flight option has no page at a store: its card carries no url, and its productId picks it. */
202
+ function productLink(product) {
203
+ const productId = pickId(product);
204
+ return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null };
205
+ }
200
206
  function productsOfCatalog(catalog) {
201
207
  return (catalog.items ?? [])
202
208
  .filter((item) => item.name)
@@ -299,9 +305,67 @@ function withMessages(result, turn) {
299
305
  const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
300
306
  return { ...result, messages: sent.flatMap(toMessages) };
301
307
  }
308
+ /** True when the app showed every message of an answer that has one, each as written and in order. */
309
+ function readEvery(told, messages) {
310
+ const written = messages.map((message) => message.trim()).filter((message) => message !== '');
311
+ return written.length > 0 && written.length === told.length && written.every((message, i) => message === told[i].trim());
312
+ }
313
+ /**
314
+ * The steps the shopper answers in the chat, none of which has a link, and the line that asks the shopper for one when
315
+ * a reply asks for something else instead. Each is written for the shopper, as Agentcard's messages are: an app that
316
+ * shows Agentcard's messages as they stream shows it in place of the ask it replaces.
317
+ */
318
+ const CHAT_STEP_ASKS = {
319
+ shipping_details: "What's the shipping address for this order?",
320
+ guest_details: 'What name, email and phone should it go under?',
321
+ verification_code: "What's the verification code the store just sent you?",
322
+ };
323
+ /** Every web address in a text, with or without its scheme. */
324
+ const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi;
325
+ /** An ask for a payment card: "add your card", "save a card", "add a payment card". "Added the gift card" is a cart line. */
326
+ const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:payment|credit|debit)\s+)?card\b/i;
327
+ /**
328
+ * Whether a streamed message waits for the answer before the shopper reads it: it asks for a card or points at a link,
329
+ * which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
330
+ * it, so the shopper reads them in order; a message before it goes out at once.
331
+ */
332
+ function waitsForAnswer(message) {
333
+ return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
334
+ }
335
+ /**
336
+ * The answer with its reply asking for the step Agentcard named, when that step is answered in the chat (an address, a
337
+ * guest's details, a code) and a paragraph of the reply asks for a card, or sends the shopper to a link the answer does
338
+ * not hand over. Agentcard holds the add-card link back while the address comes first, yet its reply can still ask for
339
+ * the card, with the link pasted in or with none: every link is taken out of a reply, so the shopper read "add your card
340
+ * to place the order:" and no link came (2026-10-26, Commerce SDK playground, a Walmart cart with no shipping address).
341
+ * The paragraph gives way to a line that asks the shopper for the named step, so the reply is no longer Agentcard's as
342
+ * written and the model words it. A streamed message that asks for a card or points at a link waits for the answer
343
+ * (`waitsForAnswer`), so an app showing Agentcard's messages as they stream shows that line in its place. A link to a product, store or cart line the answer lists is not held back, and leaves the reply
344
+ * as it is, as does a cart line about a card that is a product ("added the gift card").
345
+ */
346
+ function askingForNamedStep(result, next) {
347
+ const ask = next ? CHAT_STEP_ASKS[next] : undefined;
348
+ if (!ask || typeof result.reply !== 'string')
349
+ return result;
350
+ const known = [
351
+ ...result.links.map((link) => link.url),
352
+ ...result.products.map((product) => product.url),
353
+ ...result.stores.map((store) => store.url),
354
+ ...result.carts.flatMap((c) => c.items.map((line) => line.url)),
355
+ ...(result.cart?.items.map((line) => line.url) ?? []),
356
+ ].filter((url) => typeof url === 'string' && url !== '');
357
+ // The answer hands over no add-card link beside a step that comes first (onlyNextStepLinks), so a card ask is a dead end.
358
+ const held = (paragraph) => CARD_ASK.test(paragraph) ||
359
+ (paragraph.match(URL_IN_TEXT) ?? []).some((url) => !known.some((page) => page === url || page.startsWith(url) || url.startsWith(page)));
360
+ const paragraphs = result.reply.split(/\n\s*\n/);
361
+ if (!paragraphs.some(held))
362
+ return result;
363
+ const kept = paragraphs.filter((paragraph) => !held(paragraph) && paragraph.trim() !== '');
364
+ return { ...result, reply: [...kept, ask].join('\n\n') };
365
+ }
302
366
  /** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
303
367
  function resultOfTurn(turn) {
304
- return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
368
+ return askingForNamedStep(onlyNextStepLinks(resultOfStatus(turn), turn.next_action), turn.next_action);
305
369
  }
306
370
  /** A purchase answer, read for its status, with every link the answer carries. */
307
371
  function resultOfStatus(turn) {
@@ -600,13 +664,23 @@ export function buyTool(options) {
600
664
  const sendLink = o.sendLink;
601
665
  if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
602
666
  throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
667
+ if (o.onProducts !== undefined && typeof o.onProducts !== 'function')
668
+ throw new TypeError('Pass onProducts as a function that takes the products a search found.');
669
+ if (o.onText !== undefined && typeof o.onText !== 'function')
670
+ throw new TypeError('Pass onText as a function that takes each message Agentcard writes.');
671
+ if (o.onCart !== undefined && typeof o.onCart !== 'function')
672
+ throw new TypeError('Pass onCart as a function that takes the carts each time a call builds or changes one.');
603
673
  if (o.stream !== undefined && o.stream !== false)
604
674
  throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
605
675
  const onProgress = o.onProgress;
676
+ for (const key of ['onStatus', 'onFound']) {
677
+ if (o[key] !== undefined && typeof o[key] !== 'function')
678
+ throw new TypeError(`Pass ${key} as a function, or leave it out.`);
679
+ }
606
680
  if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
607
681
  throw new TypeError('Pass earlyCards as true or false.');
608
- // A call streams when it has someone to tell: a stage for onProgress, or early cards for the links of its turn.
609
- const earlyCards = o.earlyCards === true;
682
+ // A search's cards go out the moment it answers, unless the app waits for the answer that names them.
683
+ const earlyCards = o.earlyCards !== false;
610
684
  if (o.onResult !== undefined && typeof o.onResult !== 'function') {
611
685
  throw new TypeError('Pass onResult as a function that takes each answer.');
612
686
  }
@@ -614,6 +688,9 @@ export function buyTool(options) {
614
688
  if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
615
689
  throw new TypeError('Pass replyAsWritten as true or false.');
616
690
  const writtenByDefault = o.replyAsWritten === true;
691
+ if (o.direct !== undefined && typeof o.direct !== 'boolean')
692
+ throw new TypeError('Pass direct as true or false.');
693
+ const directOn = o.direct === true;
617
694
  const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
618
695
  if (typeof wait !== 'function')
619
696
  throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
@@ -666,6 +743,11 @@ export function buyTool(options) {
666
743
  tell: o.onWorking,
667
744
  onResult,
668
745
  onProgress,
746
+ onStatus: o.onStatus,
747
+ onFound: o.onFound,
748
+ onProducts: o.onProducts,
749
+ onText: o.onText,
750
+ onCart: o.onCart,
669
751
  links: null,
670
752
  results: null,
671
753
  pages: null,
@@ -689,6 +771,11 @@ export function buyTool(options) {
689
771
  let lastReply = null;
690
772
  /** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
691
773
  let lastCartCount = 0;
774
+ /**
775
+ * True when the last answer was a checkout that ended with no order and no cart waiting (`declined`): a new order then
776
+ * has no cart to drop, so it goes to the same purchase too (answerIn).
777
+ */
778
+ let declinedNoCart = false;
692
779
  /** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
693
780
  let lastConversationId = null;
694
781
  let lastCarts = [];
@@ -718,6 +805,10 @@ export function buyTool(options) {
718
805
  const said = productWords(text);
719
806
  return said !== '' && shownNames.some((name) => said.includes(name));
720
807
  };
808
+ /** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
809
+ let lastAnswerProducts = [];
810
+ let lastAnswerStores = [];
811
+ let previousTurn = null;
721
812
  /** The names of the stores the last answer that listed stores showed: a message that names one, as a tap on its card does, picks it. */
722
813
  let shownStoreNames = [];
723
814
  /** True for a pick of a product or a store the shopper saw, by its name. */
@@ -1143,9 +1234,7 @@ export function buyTool(options) {
1143
1234
  if (!before.has(key)) {
1144
1235
  try {
1145
1236
  // The id the model reads and a pick takes, never the store page: the page is `url`.
1146
- const productId = pickId(product);
1147
- // A flight option has no page at a store: its card carries no url, and its productId picks it.
1148
- await send({ kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null });
1237
+ await send(productLink(product));
1149
1238
  }
1150
1239
  catch {
1151
1240
  continue;
@@ -1325,7 +1414,11 @@ export function buyTool(options) {
1325
1414
  // With carts at more than one store waiting, a new order would drop every one of them: "cancel the
1326
1415
  // reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
1327
1416
  // The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
1328
- const newOrder = input.new_order && lastCartCount < 2;
1417
+ // After a checkout that ended with no order and no cart, a new order has no cart to drop: the shopper's yes to
1418
+ // trying again went out as one, and the new conversation searched again and asked for the delivery address a
1419
+ // second time (Commerce SDK playground, duralexusa.com, 2026-10-27). The ask stays in the purchase that holds
1420
+ // the product, the size and the address, until the next answer.
1421
+ const newOrder = input.new_order && lastCartCount < 2 && !declinedNoCart;
1329
1422
  // A new order is never a confirm: a model that sets both drops the cart and places nothing, even
1330
1423
  // when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
1331
1424
  const confirm = input.confirm && !input.new_order;
@@ -1374,8 +1467,10 @@ export function buyTool(options) {
1374
1467
  const callTurn = t.number;
1375
1468
  const send = senderOf(t);
1376
1469
  const progressOf = t.onProgress;
1377
- // A confirm streams as a search does, and only a search's products go out early.
1378
- const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
1470
+ // Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
1471
+ // confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
1472
+ // has no handler, and the client asks for the whole answer.
1473
+ const streams = o.stream !== false;
1379
1474
  const early = earlyCards && !!send && !confirmSent;
1380
1475
  running += 1;
1381
1476
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
@@ -1384,21 +1479,114 @@ export function buyTool(options) {
1384
1479
  try {
1385
1480
  let turn = null;
1386
1481
  let refusal;
1387
- // Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
1482
+ /** Each message of Agentcard's answer the app showed through onText, in the order the shopper read them: every one means the shopper read the reply. */
1483
+ const told = [];
1484
+ /**
1485
+ * The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
1486
+ * and every message after it, so the shopper reads them in the order Agentcard wrote them.
1487
+ */
1488
+ const waiting = [];
1489
+ /** The products past the cards of the catalog this call heard last, for the count that follows it. */
1490
+ let moreFound = null;
1491
+ /** Whether this call's cards went out, and a count that came before them. */
1492
+ let cardsOut = false;
1493
+ let heldCount = null;
1494
+ /** Hands the app one count with the products past the cards of the catalog it counts. */
1495
+ const tellCount = async ({ conversationId, shown, found }) => {
1496
+ const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
1497
+ moreFound = null;
1498
+ try {
1499
+ await t.onFound({ conversationId, shown, found, more });
1500
+ }
1501
+ catch {
1502
+ // The app's display failing never fails the call.
1503
+ }
1504
+ };
1505
+ // Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
1506
+ // answers, and each message and cart to the app as Agentcard writes it.
1388
1507
  const handlers = streams
1389
1508
  ? {
1390
1509
  ...(progressOf ? { onProgress: (progress) => { try {
1391
1510
  progressOf(progress);
1392
1511
  }
1393
1512
  catch { /* the app's display failing never fails the call */ } } } : {}),
1394
- ...(early
1513
+ ...(t.onStatus ? { onStatus: (status) => { try {
1514
+ t.onStatus(status);
1515
+ }
1516
+ catch { /* the app's display failing never fails the call */ } } } : {}),
1517
+ // A count counts the cards the shopper already holds, so it comes only where the cards go out early (earlyCards):
1518
+ // with the cards held for the answer, the answer can list fewer than the count would say.
1519
+ ...(t.onFound && early
1520
+ ? {
1521
+ onFound: async ({ conversationId, shown, found }) => {
1522
+ // A count that comes before any card of this call (find mode's) waits for the cards it counts.
1523
+ if (!cardsOut) {
1524
+ heldCount = { conversationId, shown, found };
1525
+ return;
1526
+ }
1527
+ await tellCount({ conversationId, shown, found });
1528
+ },
1529
+ }
1530
+ : {}),
1531
+ ...(early || t.onProducts
1395
1532
  ? {
1396
1533
  onCatalog: async (update) => {
1534
+ try {
1535
+ await t.onProducts?.(productsOfCatalog(update.catalog), { conversationId: update.conversationId, asOf: update.asOf, merchantName: update.catalog.merchant_name });
1536
+ }
1537
+ catch {
1538
+ // The app's display failing never fails the call.
1539
+ }
1540
+ // The products past the cards are read after the cards went out, so the first card never waits for them.
1541
+ const keepMore = () => { if (t.onFound && early)
1542
+ moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
1543
+ if (!early)
1544
+ return;
1397
1545
  // Products arrived before the instant reply was due: they are the reply.
1398
1546
  ack?.cancel();
1399
1547
  await t.ackOut;
1400
1548
  // The turn's own search presents its products, as a reply that lists them does.
1401
1549
  await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
1550
+ keepMore();
1551
+ cardsOut = true;
1552
+ // A count held for these cards goes out now, unless the catalog carries its own, which follows it.
1553
+ const held = heldCount;
1554
+ heldCount = null;
1555
+ if (held && update.found === null)
1556
+ await tellCount(held);
1557
+ },
1558
+ }
1559
+ : {}),
1560
+ ...(t.onText
1561
+ ? {
1562
+ onSegment: async ({ text, conversationId }) => {
1563
+ // A message that lands before the instant reply was due is the reply; one after it follows it.
1564
+ ack?.cancel();
1565
+ await t.ackOut;
1566
+ // A card ask or a link waits for the answer, which may name a step that comes first, and every message after it waits behind it.
1567
+ if (waiting.length > 0 || waitsForAnswer(text)) {
1568
+ waiting.push({ text, conversationId });
1569
+ return;
1570
+ }
1571
+ try {
1572
+ await t.onText(text, { conversationId });
1573
+ told.push(text);
1574
+ }
1575
+ catch {
1576
+ // A message the app failed to show was never read, so the model words the answer.
1577
+ }
1578
+ },
1579
+ }
1580
+ : {}),
1581
+ ...(t.onCart
1582
+ ? {
1583
+ onCart: async ({ cart, carts, conversationId }) => {
1584
+ try {
1585
+ await t.onCart(cart, { carts, conversationId });
1586
+ }
1587
+ catch {
1588
+ // The app's display failing never fails the call.
1589
+ }
1402
1590
  },
1403
1591
  }
1404
1592
  : {}),
@@ -1418,6 +1606,9 @@ export function buyTool(options) {
1418
1606
  // The store was still building the last request's cart: once it is in, the request that was refused goes as it was
1419
1607
  // sent, a step's own request too (a saved ask is no longer saved once its step sent it).
1420
1608
  if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
1609
+ // Only the answer of the request sent again counts toward what the shopper read.
1610
+ told.length = 0;
1611
+ waiting.length = 0;
1421
1612
  try {
1422
1613
  turn = await client.buy(sent, handlers);
1423
1614
  refusal = undefined;
@@ -1429,14 +1620,63 @@ export function buyTool(options) {
1429
1620
  ack?.cancel();
1430
1621
  const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
1431
1622
  await t.ackOut;
1623
+ // The streamed messages that waited for the answer go out in order: each the answer kept as written, and a card ask or
1624
+ // a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
1625
+ // before any link of the answer.
1626
+ let replaced = false;
1627
+ if (waiting.length > 0 && t.onText) {
1628
+ const rewritten = !!turn && settled.reply !== turn.reply;
1629
+ const kept = new Set((rewritten ? toMessages(settled.reply) : []).map((message) => message.trim()));
1630
+ for (const message of waiting.splice(0)) {
1631
+ if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
1632
+ replaced = true;
1633
+ continue;
1634
+ }
1635
+ try {
1636
+ await t.onText(message.text, { conversationId: message.conversationId });
1637
+ told.push(message.text);
1638
+ }
1639
+ catch {
1640
+ // A message the app failed to show was never read, so the model words the answer.
1641
+ }
1642
+ }
1643
+ const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
1644
+ if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
1645
+ try {
1646
+ await t.onText(stepAsk, { conversationId: settled.conversation_id });
1647
+ told.push(stepAsk);
1648
+ }
1649
+ catch {
1650
+ // Not shown, so the model asks for the step.
1651
+ }
1652
+ }
1653
+ }
1654
+ // Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
1655
+ // whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
1656
+ const read = !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
1432
1657
  const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1433
- const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
1658
+ // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
1659
+ if (heldCount && (delivered.products?.length ?? 0) > 0) {
1660
+ const held = heldCount;
1661
+ heldCount = null;
1662
+ await tellCount(held);
1663
+ }
1664
+ // The message that asks for a step link already went out, so the link follows it now instead of waiting for the model's.
1665
+ // The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
1666
+ if (read)
1667
+ await t.releaseNow?.();
1668
+ const result = {
1669
+ ...delivered,
1670
+ ...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
1671
+ ...(read ? { shopper_already_read_reply: true } : {}),
1672
+ };
1434
1673
  lastNextAction = result.next_action;
1435
1674
  lastReply = result.reply;
1436
- // A refusal reports no carts, so only a turn's answer says how many wait.
1675
+ // A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
1437
1676
  if (turn) {
1438
1677
  lastCartCount = result.carts.length;
1439
1678
  lastCarts = result.carts;
1679
+ declinedNoCart = result.status === 'declined' && result.carts.length === 0;
1440
1680
  }
1441
1681
  if (result.conversation_id)
1442
1682
  lastConversationId = result.conversation_id;
@@ -1455,6 +1695,8 @@ export function buyTool(options) {
1455
1695
  const storesShown = shownStores(result.stores);
1456
1696
  if (storesShown.length > 0)
1457
1697
  shownStoreNames = storesShown.map((store) => store.name);
1698
+ lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
1699
+ lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
1458
1700
  t.results?.push(result);
1459
1701
  if (t.onResult) {
1460
1702
  try {
@@ -1540,7 +1782,7 @@ export function buyTool(options) {
1540
1782
  const own = options ?? {};
1541
1783
  if (typeof own !== 'object')
1542
1784
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1543
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
1785
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
1544
1786
  if (own[key] !== undefined && typeof own[key] !== 'function')
1545
1787
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1546
1788
  }
@@ -1555,9 +1797,10 @@ export function buyTool(options) {
1555
1797
  if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
1556
1798
  throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
1557
1799
  const written = own.replyAsWritten ?? writtenByDefault;
1800
+ const hearText = own.onText ?? o.onText;
1558
1801
  // Messages are spaced only for an app that hears each one as it goes out.
1559
1802
  const pauseOf = paceOf(own.pace);
1560
- const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
1803
+ const feed = itemFeed(onItem || onMessage || hearText ? pauseOf : null, typingHook(own.onTyping));
1561
1804
  // With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
1562
1805
  const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
1563
1806
  const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
@@ -1616,12 +1859,20 @@ export function buyTool(options) {
1616
1859
  ...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
1617
1860
  onResult: own.onResult ?? onResult,
1618
1861
  onProgress: own.onProgress ?? onProgress,
1862
+ onStatus: own.onStatus ?? o.onStatus,
1863
+ onFound: own.onFound ?? o.onFound,
1864
+ onProducts: own.onProducts ?? o.onProducts,
1865
+ // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
1866
+ // an app that passes onItem too hears it once.
1867
+ onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
1868
+ onCart: own.onCart ?? o.onCart,
1619
1869
  tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1620
1870
  links: [],
1621
1871
  results: [],
1622
1872
  };
1623
1873
  linkNow = state.sendLink;
1624
1874
  state.release = releaseLate;
1875
+ state.releaseNow = releaseForReply;
1625
1876
  state.sendLink = (link) => {
1626
1877
  if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
1627
1878
  return linkNow(link);
@@ -1680,6 +1931,13 @@ export function buyTool(options) {
1680
1931
  }
1681
1932
  const links = state.links;
1682
1933
  const results = state.results;
1934
+ // A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
1935
+ const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
1936
+ const direct = directOn && !event && running === 0
1937
+ ? directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores })
1938
+ : null;
1939
+ if (!event)
1940
+ previousTurn = state;
1683
1941
  // What Agentcard told the shopper that no history the model ran with holds yet.
1684
1942
  const kept = client.company ? client.keptMessages(externalUserId) : [];
1685
1943
  let handed = null;
@@ -1737,6 +1995,7 @@ export function buyTool(options) {
1737
1995
  reaction: { get: () => state.reaction, enumerable: false },
1738
1996
  links: { get: () => [...links], enumerable: false },
1739
1997
  results: { get: () => [...results], enumerable: false },
1998
+ direct: { value: direct, enumerable: false },
1740
1999
  replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
1741
2000
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
1742
2001
  // Each message joins the feed the moment it is cut, so a link the tool hands over during a pause never overtakes text already written.
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;
@@ -155,7 +253,8 @@ export interface BuyCart {
155
253
  items: BuyCartLine[];
156
254
  /** The items alone, or null from an older API. */
157
255
  subtotalCents: number | null;
158
- serviceFeesCents: number;
256
+ serviceFeesCents: number | null;
257
+ serviceFeesStatus?: 'known' | 'pending' | 'unknown';
159
258
  /** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
160
259
  merchantFeeLines: BuyCartFeeLine[] | null;
161
260
  tipCents: number;
@@ -393,6 +492,7 @@ export declare class AgentcardClient {
393
492
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
394
493
  private readonly inbox;
395
494
  private readonly deliveredIds;
495
+ private readonly trace;
396
496
  constructor(options: AgentcardClientOptions);
397
497
  /** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
398
498
  get company(): boolean;
@@ -459,3 +559,4 @@ export declare class AgentcardClient {
459
559
  private renewAfter;
460
560
  private exchange;
461
561
  }
562
+ export {};