@agent-cards/commerce-sdk 0.16.0 → 0.17.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
@@ -36,10 +36,10 @@ import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolIn
36
36
  import { directCall, isProductAsk } from './direct.js';
37
37
  import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
38
38
  import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
39
- import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
39
+ import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
40
40
  import { isStale } from './shopper-message.js';
41
41
  import { actionLinkId, isActionLink } from './action-links.js';
42
- import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
42
+ import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
43
43
  /** The action a product of `merchant` takes (BuyToolProductAction). */
44
44
  export function productAction(merchant) {
45
45
  if (merchant === 'reservations')
@@ -110,8 +110,8 @@ const RESUME_SKEW_MS = 2 * 60_000;
110
110
  const STOPPED_ORDER = new Set(['failed', 'cancelled']);
111
111
  /** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
112
112
  const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
113
- /** How long Agentcard has to answer before the instant reply goes out. */
114
- const ACK_DELAY_MS = 1_500;
113
+ /** How long a turn shows the shopper nothing before the line that says it is on it goes out. */
114
+ const ACK_DELAY_MS = 1_000;
115
115
  /** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
116
116
  const SAME_TURN_MS = 15_000;
117
117
  /** A product name shorter than this, such as `AA`, is too short to tell a pick from a search. */
@@ -370,7 +370,8 @@ function onlyNextStepLinks(result, next) {
370
370
  */
371
371
  function withMessages(result, turn) {
372
372
  const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
373
- return { ...result, messages: sent.flatMap(toMessages) };
373
+ // One joiner across every message, so a message that is only a lead-in goes with the next one rather than out of the list.
374
+ return { ...result, messages: joinLeadIns(sent.flatMap(paragraphsOf)) };
374
375
  }
375
376
  /** True when the app showed every message of an answer that has one, each as written and in order. */
376
377
  function readEvery(told, messages) {
@@ -550,6 +551,8 @@ function senderOf(t) {
550
551
  */
551
552
  function itemFeed(pauseOf, typing) {
552
553
  const items = [];
554
+ // How many items joined the feed, sent or still going out: one means the shopper has something to read.
555
+ let pushed = 0;
553
556
  // The item going out now, or null when none is: an item pushed while none is goes out at once.
554
557
  let tail = null;
555
558
  let lastAt = null;
@@ -584,6 +587,7 @@ function itemFeed(pauseOf, typing) {
584
587
  * unlisted, with no pause, as for the rest of a reply whose earlier message failed.
585
588
  */
586
589
  const push = (item, send, skip = () => false) => {
590
+ pushed += 1;
587
591
  // Each message's pause is chosen once, when it joins the feed, so a pace that varies never makes it wait twice.
588
592
  const pause = item.kind === 'text' && pauseOf ? pauseOf(item.text) : 0;
589
593
  const waitFor = () => (pause > 0 && lastAt !== null ? lastAt + pause - Date.now() : 0);
@@ -611,7 +615,7 @@ function itemFeed(pauseOf, typing) {
611
615
  tail = settled;
612
616
  return run;
613
617
  };
614
- return { items, push };
618
+ return { items, push, pushed: () => pushed };
615
619
  }
616
620
  /**
617
621
  * One reply's messages in a turn's feed: each one takes its place in the feed the moment it is cut,
@@ -748,6 +752,8 @@ export function buyTool(options) {
748
752
  throw new TypeError('Pass earlyCards as true or false.');
749
753
  // A search's cards go out the moment it answers, unless the app waits for the answer that names them.
750
754
  const earlyCards = o.earlyCards !== false;
755
+ // Every call streams unless the app waits for whole answers (`stream: false`).
756
+ const streamCalls = o.stream !== false;
751
757
  if (o.onResult !== undefined && typeof o.onResult !== 'function') {
752
758
  throw new TypeError('Pass onResult as a function that takes each answer.');
753
759
  }
@@ -890,6 +896,8 @@ export function buyTool(options) {
890
896
  };
891
897
  /** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
892
898
  let lastAnswerProducts = [];
899
+ /** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
900
+ let lastTappable = [];
893
901
  let lastAnswerStores = [];
894
902
  let previousTurn = null;
895
903
  /** 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. */
@@ -964,8 +972,42 @@ export function buyTool(options) {
964
972
  const holdAck = (ask, t, step = null) => {
965
973
  const onWorking = t.onWorking;
966
974
  const reacting = t.onReaction;
967
- if ((!onWorking && !reacting) || t.acked)
975
+ // A turn the app ended (`turn.end()`) holds no acknowledgement for a call it still runs.
976
+ if ((!onWorking && !reacting) || t.acked || t.ended)
968
977
  return null;
978
+ const model = t.modelTurn;
979
+ if (model && onWorking && model.writesThrough()) {
980
+ // The app's model called the tool. When it wrote its own line first, that line is the turn's acknowledgement; when
981
+ // its first output is this call, the shopper hears the wait line, no sooner than ackDelayMs after the turn opened.
982
+ model.callStarted = true;
983
+ if (model.spoke || model.shown())
984
+ return null;
985
+ t.acked = true;
986
+ const line = slowStepLine(step === 'confirm' ? 'confirm' : step ?? t.picked ?? 'wait');
987
+ let waiting = true;
988
+ const waitTimer = setTimeout(() => {
989
+ waiting = false;
990
+ // The model may have written its own line while this call ran: that line is the turn's acknowledgement, so the
991
+ // wait line is dropped and the turn's reply is free for a later call.
992
+ if (model.spoke || model.shown() || t.ended) {
993
+ t.acked = false;
994
+ return;
995
+ }
996
+ t.waitLineOut = true;
997
+ t.ackOut = Promise.resolve()
998
+ .then(() => onWorking(line))
999
+ .then(() => { t.ackDelivered = true; }, () => { });
1000
+ }, Math.max(0, model.openedAt + ackDelayMs - Date.now()));
1001
+ return {
1002
+ cancel: () => {
1003
+ if (!waiting)
1004
+ return;
1005
+ waiting = false;
1006
+ clearTimeout(waitTimer);
1007
+ t.acked = false;
1008
+ },
1009
+ };
1010
+ }
969
1011
  // A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
970
1012
  const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
971
1013
  // A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
@@ -974,10 +1016,19 @@ export function buyTool(options) {
974
1016
  // A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
975
1017
  if (!reacting && !slow && !neutral && !isNewRequest(ask))
976
1018
  return null;
1019
+ // A streamed search's line is Agentcard's (onAck), written from the conversation while it searches: the tool adds no
1020
+ // template of its own, unless the app writes the words.
1021
+ if (!reacting && !slow && !neutral && streamCalls && !writeAck)
1022
+ return null;
977
1023
  t.acked = true;
978
1024
  let held = true;
979
1025
  const timer = setTimeout(() => {
980
1026
  held = false;
1027
+ // The app ended the turn while this call ran, as after its model failed: no 👀 and no line follow its error.
1028
+ if (t.ended) {
1029
+ t.acked = false;
1030
+ return;
1031
+ }
981
1032
  // With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
982
1033
  if (reacting) {
983
1034
  react(t, reacting);
@@ -1002,7 +1053,7 @@ export function buyTool(options) {
1002
1053
  lastTemplate = nextTemplate(lastTemplate);
1003
1054
  // A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
1004
1055
  // tool is working, and never that it is looking for anything.
1005
- text = neutral ? waitAt(lastTemplate) : ackAt(lastTemplate, ask);
1056
+ text = neutral ? slowStepLine('wait') : ackAt(lastTemplate, ask);
1006
1057
  }
1007
1058
  if (typeof text !== 'string' || text.trim() === '') {
1008
1059
  t.acked = false;
@@ -1623,20 +1674,31 @@ export function buyTool(options) {
1623
1674
  // A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
1624
1675
  // shopper's own yes to this exact cart goes in, for that cart's hash.
1625
1676
  const plan = t.event ? await stepPlan(t.event) : null;
1626
- const sent = plan && 'body' in plan ? plan.body : body;
1627
- const confirmSent = plan ? 'body' in plan && sent.confirm !== undefined : confirm;
1677
+ const planned = plan && 'body' in plan ? plan.body : body;
1678
+ const confirmSent = plan ? 'body' in plan && planned.confirm !== undefined : confirm;
1679
+ // Agentcard writes the line that says what it is looking for (onAck) only where the shopper can read it before the
1680
+ // cards and has read nothing yet: a streamed ask, with somewhere to show it, that no reaction, reply or model's words
1681
+ // acknowledged already, in an app that asked for no acknowledgement of its own (`ack: 'none'` or `ack: false` gets
1682
+ // none, there as here) and acknowledges in text: an app that reacts (`ack: 'reaction'`, or an `onReaction` the turn
1683
+ // routes to) reads no line of text, even through `onText`. Every other ask says so, and Agentcard spends no model call on it.
1684
+ const agentcardAcks = streamCalls && ackMode !== 'none' && ackMode !== 'reaction' && !t.onReaction && !t.event && !confirmSent && !pick && !t.acked && (!!t.onWorking || !!t.onText) && (t.agentcardAcks?.(ask) ?? false);
1685
+ /** What goes to Agentcard: the planned request, with `ack: false` when the shopper reads no line of Agentcard's. */
1686
+ let sent = planned;
1628
1687
  const callTurn = t.number;
1629
1688
  const send = senderOf(t);
1630
1689
  const progressOf = t.onProgress;
1631
1690
  // Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
1632
1691
  // confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
1633
1692
  // has no handler, and the client asks for the whole answer.
1634
- const streams = o.stream !== false;
1693
+ const streams = streamCalls;
1635
1694
  const early = earlyCards && !!send && !confirmSent;
1636
1695
  running += 1;
1696
+ t.called = true;
1637
1697
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1638
1698
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1639
1699
  const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1700
+ if (ack)
1701
+ (t.holds ??= new Set()).add(ack.cancel);
1640
1702
  /** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
1641
1703
  let settleLeft = null;
1642
1704
  try {
@@ -1651,16 +1713,51 @@ export function buyTool(options) {
1651
1713
  const waiting = [];
1652
1714
  /** True when the last message the shopper read ended in a colon or a question mark, so choices that open the next one are its list. */
1653
1715
  let leadIn = false;
1654
- /** Shows one of Agentcard's messages through onText, its lists as bullets (messageForShopper); `told` keeps it as Agentcard wrote it. */
1716
+ /** A message that leads in with a colon waits for the one after it (leadInJoiner), and `heldTexts` keeps it as Agentcard wrote it. */
1717
+ let leads = leadInJoiner();
1718
+ let heldTexts = [];
1719
+ /** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
1720
+ const showMessage = async (message, texts, conversationId) => {
1721
+ await t.onText(messageForShopper(message, leadIn, t.texting), { conversationId });
1722
+ leadIn = leadsIntoList(message);
1723
+ told.push(...texts);
1724
+ };
1725
+ /**
1726
+ * Shows one of Agentcard's messages through onText. One that ends on a colon, such as `two options came in under $20:`,
1727
+ * goes out with the message after it, so the shopper never reads a bubble ending on a colon.
1728
+ */
1655
1729
  const showText = async (text, conversationId) => {
1656
- await t.onText(messageForShopper(text, leadIn, t.texting), { conversationId });
1657
- leadIn = leadsIntoList(text);
1658
- told.push(text);
1730
+ const message = leads.add(text);
1731
+ heldTexts.push({ text, conversationId });
1732
+ if (message === null)
1733
+ return;
1734
+ const texts = heldTexts.map((held) => held.text);
1735
+ heldTexts = [];
1736
+ await showMessage(message, texts, conversationId);
1737
+ };
1738
+ /**
1739
+ * A lead-in no message followed, once the answer is in: its list went (the cards show it), so it goes out less its colon
1740
+ * sentence, and not at all when that was all of it. It counts as read when anything was, so the model never words again
1741
+ * what the shopper read.
1742
+ */
1743
+ const showLeftLead = async () => {
1744
+ if (heldTexts.length === 0)
1745
+ return;
1746
+ const rest = leads.end();
1747
+ const texts = heldTexts.map((held) => held.text);
1748
+ const conversationId = heldTexts[0].conversationId;
1749
+ heldTexts = [];
1750
+ if (rest)
1751
+ await showMessage(rest, texts, conversationId);
1752
+ else if (told.length > 0)
1753
+ told.push(...texts);
1659
1754
  };
1660
1755
  /** The products past the cards of the catalog this call heard last, for the count that follows it. */
1661
1756
  let moreFound = null;
1662
1757
  /** Whether this call's cards went out, and a count that came before them. */
1663
1758
  let cardsOut = false;
1759
+ /** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
1760
+ const streamedCards = [];
1664
1761
  let heldCount = null;
1665
1762
  /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1666
1763
  const cardOfId = new Map();
@@ -1769,6 +1866,7 @@ export function buyTool(options) {
1769
1866
  await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1770
1867
  // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1771
1868
  noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1869
+ streamedCards.push(...productsOfCatalog(update.catalog));
1772
1870
  keepMore();
1773
1871
  cardsOut = true;
1774
1872
  // A count held for these cards goes out now, unless the catalog carries its own, which follows it.
@@ -1806,6 +1904,24 @@ export function buyTool(options) {
1806
1904
  },
1807
1905
  }
1808
1906
  : {}),
1907
+ ...(agentcardAcks
1908
+ ? {
1909
+ // Agentcard's line for the search, such as `one sec, checking target for la croix`: it goes out as the turn's
1910
+ // instant reply, through onWorking, else as Agentcard's message, and only while the shopper has read nothing
1911
+ // else of the turn. The model then reads `shopper_already_told_looking`.
1912
+ onAck: async ({ text, conversationId }) => {
1913
+ if (t.acked || cardsOut || told.length > 0 || waiting.length > 0)
1914
+ return;
1915
+ ack?.cancel();
1916
+ t.acked = true;
1917
+ const line = text.trim();
1918
+ t.ackOut = Promise.resolve()
1919
+ .then(() => (t.onWorking ? t.onWorking(line) : t.onText?.(line, { conversationId })))
1920
+ .then(() => { t.ackDelivered = true; }, () => { });
1921
+ await t.ackOut;
1922
+ },
1923
+ }
1924
+ : {}),
1809
1925
  ...(t.onCart
1810
1926
  ? {
1811
1927
  onCart: async ({ cart, carts, conversationId }) => {
@@ -1820,6 +1936,9 @@ export function buyTool(options) {
1820
1936
  : {}),
1821
1937
  }
1822
1938
  : undefined;
1939
+ // A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
1940
+ const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
1941
+ sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
1823
1942
  if (plan && 'local' in plan) {
1824
1943
  // Nothing went to Agentcard: the answer is where the purchase stands.
1825
1944
  }
@@ -1837,6 +1956,8 @@ export function buyTool(options) {
1837
1956
  // Only the answer of the request sent again counts toward what the shopper read.
1838
1957
  told.length = 0;
1839
1958
  waiting.length = 0;
1959
+ leads = leadInJoiner();
1960
+ heldTexts = [];
1840
1961
  try {
1841
1962
  turn = await client.buy(sent, handlers);
1842
1963
  refusal = undefined;
@@ -1854,7 +1975,8 @@ export function buyTool(options) {
1854
1975
  let replaced = false;
1855
1976
  if (waiting.length > 0 && t.onText) {
1856
1977
  const rewritten = !!turn && settled.reply !== turn.reply;
1857
- const kept = new Set((rewritten ? toMessages(settled.reply) : []).map((message) => message.trim()));
1978
+ // Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
1979
+ const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
1858
1980
  for (const message of waiting.splice(0)) {
1859
1981
  if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
1860
1982
  replaced = true;
@@ -1877,6 +1999,14 @@ export function buyTool(options) {
1877
1999
  }
1878
2000
  }
1879
2001
  }
2002
+ if (t.onText) {
2003
+ try {
2004
+ await showLeftLead();
2005
+ }
2006
+ catch {
2007
+ // A message the app failed to show was never read, so the model words the answer.
2008
+ }
2009
+ }
1880
2010
  // Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
1881
2011
  // whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
1882
2012
  // A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
@@ -1892,7 +2022,7 @@ export function buyTool(options) {
1892
2022
  choiceShown = false;
1893
2023
  }
1894
2024
  }
1895
- const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
2025
+ const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
1896
2026
  const answer = withMessages(settled, turn);
1897
2027
  const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1898
2028
  // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
@@ -1956,7 +2086,23 @@ export function buyTool(options) {
1956
2086
  const storesShown = shownStores(result.stores);
1957
2087
  if (storesShown.length > 0)
1958
2088
  shownStoreNames = storesShown.map((store) => store.name);
2089
+ // The cards the shopper can tap by name are the answer's and the ones this call's searches already sent: a restaurant turn that
2090
+ // read the Burrito's options answered with the Burrito alone, beside the eight menu cards it sent first, and a tap on the
2091
+ // Burrito Bowl then found no card of its name. A place ("the last one") reads the answer's own list, in its order, and never
2092
+ // this one, so it picks the product shown in that place (Greptile on #11163).
2093
+ // Each card once, by its id: a call that sends one search's products again (a refreshed catalog) would otherwise hold the
2094
+ // same card twice, and a tap on it would read as two cards named alike (Greptile on #11163).
2095
+ const tappable = [];
2096
+ const held = new Set();
2097
+ for (const product of [...shown, ...shownProducts(streamedCards)]) {
2098
+ const id = pickId(product) ?? `name:${product.name}`;
2099
+ if (held.has(id))
2100
+ continue;
2101
+ held.add(id);
2102
+ tappable.push(product);
2103
+ }
1959
2104
  lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
2105
+ lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
1960
2106
  lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
1961
2107
  t.results?.push(result);
1962
2108
  if (t.onResult) {
@@ -1973,6 +2119,9 @@ export function buyTool(options) {
1973
2119
  // A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
1974
2120
  await settleLeft?.().catch(() => { });
1975
2121
  ack?.cancel();
2122
+ if (ack)
2123
+ t.holds?.delete(ack.cancel);
2124
+ t.dropWaitLine?.();
1976
2125
  running -= 1;
1977
2126
  lastEndedAt = Date.now();
1978
2127
  }
@@ -2152,7 +2301,29 @@ export function buyTool(options) {
2152
2301
  return Promise.resolve();
2153
2302
  };
2154
2303
  /** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
2155
- const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), skip);
2304
+ let modelWrote = false;
2305
+ /** Whether the model's first words came before any call of the turn: set by its first delta or `say`, since a paragraph is cut later. */
2306
+ let wordsBeforeCall = null;
2307
+ const firstWords = (words) => {
2308
+ if (wordsBeforeCall === null && /\S/.test(String(words ?? '')))
2309
+ wordsBeforeCall = !state.called;
2310
+ };
2311
+ const text = (message, skip) => {
2312
+ const first = !modelWrote;
2313
+ modelWrote = true;
2314
+ const show = (left) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), left);
2315
+ // The wait line already told the shopper the turn is on it: the model's own acknowledgement, its first message
2316
+ // written before any call, such as `on it, looking for a dryer under $20`, would be a second line, so it stays in
2317
+ // the model's own history and never reaches the shopper. A first message that asks, answers or says what a search
2318
+ // came to still goes out, and so does every message after it. (2026-10-27: "dryer under 20" read `one moment`,
2319
+ // then `on it, looking for a dryer under $20`.)
2320
+ // A wait line the app failed to send reached nobody (`ackDelivered`), so the model's own line goes out in its
2321
+ // place, in the feed's order: a failed send costs that line alone, never both.
2322
+ if (first && state.waitLineOut && (wordsBeforeCall ?? !state.called) && readsAsAckLine(message)) {
2323
+ return show(() => state.ackDelivered || (skip?.() ?? false));
2324
+ }
2325
+ return show(skip);
2326
+ };
2156
2327
  let ack = null;
2157
2328
  const working = state.onWorking;
2158
2329
  const reacting = state.onReaction;
@@ -2182,7 +2353,9 @@ export function buyTool(options) {
2182
2353
  // A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
2183
2354
  react(state, reacting);
2184
2355
  }
2185
- else if (text !== null && working) {
2356
+ else if (text !== null && working && (writeAck || !streamCalls)) {
2357
+ // A streamed search's line is Agentcard's to write (onAck), from the conversation, while it searches; the
2358
+ // tool's template goes out at once only for an answer the app waits for whole, or in the app's own words.
2186
2359
  ack = text;
2187
2360
  state.acked = true;
2188
2361
  let sent;
@@ -2207,7 +2380,59 @@ export function buyTool(options) {
2207
2380
  ? null
2208
2381
  : event
2209
2382
  ? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
2210
- : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores });
2383
+ : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, stores: lastAnswerStores });
2384
+ // Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
2385
+ // or the wait line below.
2386
+ state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
2387
+ /** Says the turn is on it, once, in fixed words that name nothing the shopper said, unless something went out first. */
2388
+ const sayWaiting = (line) => {
2389
+ if (state.acked || state.ended || feed.pushed() > 0 || !writesThroughTurn)
2390
+ return;
2391
+ state.acked = true;
2392
+ state.waitLineOut = true;
2393
+ state.ackOut = Promise.resolve()
2394
+ .then(() => working(line))
2395
+ .then(() => { state.ackDelivered = true; }, () => { });
2396
+ };
2397
+ // Only where the tool sees what the shopper read: the app writes the model's words through the turn.
2398
+ if (!event && working && !reacting && direct !== null && direct.reason !== 'product_ask') {
2399
+ // A pick, a confirm or an answer the turn sends straight that has shown nothing after ackDelayMs says it is on it. A
2400
+ // product ask hears Agentcard's line (onAck).
2401
+ const waitTimer = setTimeout(() => sayWaiting(slowStepLine(direct.reason === 'confirm' ? 'confirm' : state.picked ?? 'wait')), ackDelayMs);
2402
+ waitTimer.unref?.();
2403
+ // A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed pick.
2404
+ state.dropWaitLine = () => clearTimeout(waitTimer);
2405
+ }
2406
+ else if (!event && working && !reacting && direct !== null) {
2407
+ // A search the turn sends straight hears Agentcard's line (onAck). When that line never comes, as when Agentcard's
2408
+ // model writes none or its line is dropped, and the shopper has seen nothing, no line and no card, by twice
2409
+ // ackDelayMs, the wait line goes out then, the same rule as a model that has written nothing and called nothing.
2410
+ // Once it went out, a line of Agentcard's that comes later is dropped (onAck reads `acked`), so the shopper reads one
2411
+ // line. A call that already answered leaves the turn to its reply. (2026-10-27: "i want one called ardor" showed
2412
+ // nothing for 9.1 s.)
2413
+ const waitTimer = setTimeout(() => {
2414
+ if (results.length === 0)
2415
+ sayWaiting(slowStepLine('wait'));
2416
+ }, 2 * ackDelayMs);
2417
+ waitTimer.unref?.();
2418
+ // A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed search.
2419
+ state.dropWaitLine = () => clearTimeout(waitTimer);
2420
+ }
2421
+ else if (!event && working && !reacting && direct === null) {
2422
+ // The app's model runs this turn, and its first output decides: its own line first means no wait line; a call first
2423
+ // gets the wait line from that call (holdAck); and a model that has written nothing and called nothing after twice
2424
+ // ackDelayMs gets it then.
2425
+ const model = { openedAt: Date.now(), spoke: false, callStarted: false, writesThrough: () => writesThroughTurn, shown: () => feed.pushed() > 0 };
2426
+ state.modelTurn = model;
2427
+ const waitTimer = setTimeout(() => {
2428
+ if (!model.spoke && !model.callStarted)
2429
+ sayWaiting(slowStepLine(state.picked ?? 'wait'));
2430
+ }, 2 * ackDelayMs);
2431
+ waitTimer.unref?.();
2432
+ // `turn.end()`, as when the app's model failed before it wrote or called anything, stops it: no `one moment` follows
2433
+ // the app's own error. A call that ends stops it too, since a call started means the timer would send nothing.
2434
+ state.dropWaitLine = () => clearTimeout(waitTimer);
2435
+ }
2211
2436
  if (!event)
2212
2437
  previousTurn = state;
2213
2438
  // What Agentcard told the shopper that no history the model ran with holds yet.
@@ -2262,6 +2487,17 @@ export function buyTool(options) {
2262
2487
  },
2263
2488
  enumerable: false,
2264
2489
  },
2490
+ end: {
2491
+ value: () => {
2492
+ state.ended = true;
2493
+ state.dropWaitLine?.();
2494
+ // The acknowledgement a call still running holds, a 👀 or a line for a slow pick, confirm or answer, is cancelled too.
2495
+ for (const cancel of state.holds ?? [])
2496
+ cancel();
2497
+ state.holds?.clear();
2498
+ },
2499
+ enumerable: false,
2500
+ },
2265
2501
  messages: { value: messagesFor, enumerable: false },
2266
2502
  ack: { value: ack, enumerable: false },
2267
2503
  reaction: { get: () => state.reaction, enumerable: false },
@@ -2275,13 +2511,26 @@ export function buyTool(options) {
2275
2511
  messageStream: {
2276
2512
  value: () => {
2277
2513
  writesThroughTurn = true;
2278
- return feedStream(text, releaseForReply);
2514
+ const stream = feedStream(text, releaseForReply);
2515
+ return {
2516
+ push: (delta) => {
2517
+ firstWords(delta);
2518
+ // The model's first words: its own line is the turn's acknowledgement, so no wait line follows.
2519
+ if (state.modelTurn && /\S/.test(String(delta ?? '')))
2520
+ state.modelTurn.spoke = true;
2521
+ stream.push(delta);
2522
+ },
2523
+ end: () => stream.end(),
2524
+ };
2279
2525
  },
2280
2526
  enumerable: false,
2281
2527
  },
2282
2528
  say: {
2283
2529
  value: (whole) => {
2284
2530
  writesThroughTurn = true;
2531
+ firstWords(whole);
2532
+ if (state.modelTurn && /\S/.test(String(whole ?? '')))
2533
+ state.modelTurn.spoke = true;
2285
2534
  const reply = feedReply(text);
2286
2535
  for (const message of toMessages(whole))
2287
2536
  reply.add(message);
package/dist/client.d.ts CHANGED
@@ -127,6 +127,12 @@ export interface BuyStreamHandlers {
127
127
  onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
128
128
  /** Each message Agentcard writes, the moment it is written. */
129
129
  onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
130
+ /**
131
+ * The line that says what Agentcard is looking for, such as `one sec, checking target for la croix`, written while the
132
+ * search runs on an ask for something new. It comes before the turn's cards and messages, or not at all, and the
133
+ * answer's `messages` never hold it.
134
+ */
135
+ onAck?: (update: BuySegmentUpdate) => void | Promise<void>;
130
136
  /** The carts, each time the turn builds or changes one. */
131
137
  onCart?: (update: BuyCartUpdate) => void | Promise<void>;
132
138
  /** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
@@ -234,6 +240,8 @@ export interface BuyRequest {
234
240
  delivery_address?: BuyDeliveryAddress;
235
241
  external_user_id?: string;
236
242
  user_id?: string;
243
+ /** `false` asks for no line that says what Agentcard is looking for (`onAck`): the shopper already read one. */
244
+ ack?: false;
237
245
  }
238
246
  export type BuyStatus = 'needs_input' | 'needs_approval' | 'order_placed' | 'partially_placed' | 'declined';
239
247
  export interface BuyStore {
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { isRecord } from './verify.js';
12
12
  import { listenToAgentcard } from './listen.js';
13
13
  /** The handlers that ask Agentcard to stream a turn. */
14
- const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onCart', 'onDelta', 'onAvailability'];
14
+ const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability'];
15
15
  const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
16
16
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
17
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. */
@@ -535,6 +535,9 @@ async function readStreamedTurn(res, stream, timeoutMs) {
535
535
  else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
536
536
  deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
537
537
  }
538
+ else if (event === 'ack' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onAck) {
539
+ deliver(() => stream.onAck({ conversationId: named, text: data.text }));
540
+ }
538
541
  else if (event === 'venue_availability' && stream.onAvailability && str(data.conversation_id) && Array.isArray(data.items)) {
539
542
  const items = data.items.filter(isRecord).flatMap((i) => {
540
543
  const id = str(i.id);
@@ -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 the shopper asks you to find, buy, book or order something new, such as a product, a gift idea, a meal, a table, a flight or a store page they pasted a link to, first write one short line that says what you are about to look for, then call this tool in the same reply: only what they asked for, and the store or place they named, in their own words, such as "on it, looking for la croix at target", "one sec, checking nopa for a table for 2 at 7pm" or, for a pasted link, "one sec, checking that link", never a price, a total, stock, a result, a question or that anything was found, added or ordered. A store named by its site, such as "the polishing cloth from myrqvist.com", is a store, not a link. For a yes, a pick, a size, an address, a code, a change to a cart or a question about an order, write no line first, and after this tool answers, never say again that you are looking.',
16
17
  '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.',
17
18
  'Show the shopper the cart before you confirm it.',
18
19
  '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.',
package/dist/direct.d.ts CHANGED
@@ -21,11 +21,21 @@ export interface DirectContext {
21
21
  nextAction?: BuyNextAction | null;
22
22
  /** Agentcard's last reply, the question a short answer answers. */
23
23
  asked?: string | null;
24
- /** The products Agentcard's last answer showed, in order, with the `product_id` a pick sends. */
24
+ /** The products Agentcard's last answer showed, in order, with the `product_id` a pick sends. A place (`the second one`) reads these. */
25
25
  products?: ReadonlyArray<{
26
26
  productId: string | null;
27
27
  name: string;
28
28
  }>;
29
+ /**
30
+ * Every product card the shopper can tap by its name, each once: the answer's and the ones its searches sent as they answered,
31
+ * which an answer about one item can leave out (a menu turn that answered with the Burrito beside eight menu cards). A pick by
32
+ * name (a card's tap, a product's whole name, a word only one name holds) reads these; a place never does, so `the last one` is
33
+ * the last of `products`. Left out, `products` stands for both.
34
+ */
35
+ tappable?: ReadonlyArray<{
36
+ productId: string | null;
37
+ name: string;
38
+ }>;
29
39
  /** The stores Agentcard's last answer showed, with the `store_id` a pick sends. */
30
40
  stores?: ReadonlyArray<{
31
41
  storeId: string;