@agent-cards/commerce-sdk 0.9.0 → 0.10.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
@@ -21,12 +21,70 @@
21
21
  * still finishing gets one 12-second pause and one read of the conversation.
22
22
  * A turn that outlived Agentcard's budget gets a read every 5 seconds for up
23
23
  * to a minute, until the turn ends.
24
+ *
25
+ * `turn` binds the tool to one shopper message: the calls the model makes
26
+ * through it hand their links, answers and instant reply to that turn's own
27
+ * callbacks and lists, so a shopper who sends a second message before the
28
+ * first is answered never gets one message's cards beside the other's reply.
29
+ * Every turn of one tool still shares the shopper's conversation state, such
30
+ * as the store pages already sent. A turn with `onItem` hands the app its
31
+ * messages and links as one sequence, in the order they happened, and spaces
32
+ * the messages like someone texting.
24
33
  */
25
34
  import { AgentcardApiError, AgentcardClient, } from './client.js';
26
35
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
27
- import { forModel, forModelError, money, pickId, shownProducts, shownStores } from './model-view.js';
28
- import { toMessages } from './messages.js';
29
- import { ackAt, instantAck as instantAckOf, isNewRequest, isPickText, nextTemplate } from './ack.js';
36
+ import { forModel, forModelError, money, pickId, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
37
+ import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
38
+ import { ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
39
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
40
+ /**
41
+ * The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
42
+ * words. Agentcard's words join a last assistant message of plain text, a string or text parts only,
43
+ * so no provider reads two assistant messages in a row. After anything else, such as a tool result
44
+ * or an OpenAI Responses item, they come as an assistant message of their own.
45
+ */
46
+ export function turnMessages(history, told, shopperText) {
47
+ if (history !== undefined && history !== null && !Array.isArray(history))
48
+ throw new TypeError("Pass the model's history as an array of messages, or leave it out.");
49
+ const out = [...(history ?? [])];
50
+ const said = told.map((text) => text.trim()).filter((text) => text !== '');
51
+ if (said.length > 0) {
52
+ const last = out.at(-1);
53
+ const content = isObject(last) && last.role === 'assistant' ? last.content : undefined;
54
+ if (typeof content === 'string') {
55
+ // The model's own words stay as it wrote them.
56
+ out[out.length - 1] = { ...last, content: content.trim() === '' ? said.join('\n\n') : [content, ...said].join('\n\n') };
57
+ }
58
+ else if (Array.isArray(content) && content.length > 0 && content.every((part) => isObject(part) && part.type === 'text' && typeof part.text === 'string')) {
59
+ out[out.length - 1] = { ...last, content: [...content, ...said.map((text) => ({ type: 'text', text }))] };
60
+ }
61
+ else {
62
+ out.push({ role: 'assistant', content: said.join('\n\n') });
63
+ }
64
+ }
65
+ out.push({ role: 'user', content: shopperText });
66
+ return out;
67
+ }
68
+ /** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
69
+ const modelText = (message) => stripLinks(String(message.text ?? ''));
70
+ /** How often the history held a message's text when the message first went in to the model. */
71
+ const offeredAt = new WeakMap();
72
+ /** How many times `needle` appears in `haystack`, none overlapping. */
73
+ function occurrences(haystack, needle) {
74
+ let count = 0;
75
+ for (let at = haystack.indexOf(needle); needle !== '' && at !== -1; at = haystack.indexOf(needle, at + needle.length))
76
+ count++;
77
+ return count;
78
+ }
79
+ /** The history as JSON text, to find a message it already holds, or empty text for one JSON cannot write. */
80
+ function historyText(history) {
81
+ try {
82
+ return JSON.stringify(history ?? []) ?? '';
83
+ }
84
+ catch {
85
+ return '';
86
+ }
87
+ }
30
88
  /** The pause before the one read that follows a placement Agentcard is still finishing. */
31
89
  const PLACEMENT_WAIT_MS = 12_000;
32
90
  /** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
@@ -104,7 +162,7 @@ function productsOfCatalog(catalog) {
104
162
  }
105
163
  /** The stores an answer presents, as the tool reads them. */
106
164
  function storesOf(stores) {
107
- return (stores?.items ?? []).map((store) => ({ id: store.id, name: store.name, url: store.store_page_url, etaMinutes: store.eta_minutes, linkSent: false }));
165
+ return (stores?.items ?? []).map((store) => ({ id: store.id, name: store.name, url: store.store_page_url, etaMinutes: store.eta_minutes, imageUrl: store.image_url, linkSent: false }));
108
166
  }
109
167
  /** The store login link an answer carries, read from its own fields: a link in `reply` is never pulled out. */
110
168
  function storeLoginOf(turn) {
@@ -275,6 +333,120 @@ function needsCard(link, body) {
275
333
  envelope: body,
276
334
  };
277
335
  }
336
+ /** The way a turn hands over a link, or undefined when it has nowhere to send one. */
337
+ function senderOf(t) {
338
+ if (!t.sendLink && !t.links)
339
+ return undefined;
340
+ return async (link) => {
341
+ if (t.sendLink)
342
+ await t.sendLink(link);
343
+ // A link sendLink threw on never reached the shopper, so the turn does not list it.
344
+ t.links?.push({ ...link });
345
+ };
346
+ }
347
+ /**
348
+ * A turn's items, one at a time and in order: each one goes out once the one before it settled, and a
349
+ * `text` item first waits for its pause since the item before it (`pauseOf`). `items` lists each item
350
+ * that went out, in order. A failed item stays out of `items` and never stops the next one.
351
+ */
352
+ function itemFeed(pauseOf, typing) {
353
+ const items = [];
354
+ // The item going out now, or null when none is: an item pushed while none is goes out at once.
355
+ let tail = null;
356
+ let lastAt = null;
357
+ const sent = (item) => {
358
+ items.push(item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item });
359
+ };
360
+ /** Sends one item now: settled when its send settled, and listed once it succeeded. */
361
+ const sendNow = (item, send) => {
362
+ let out;
363
+ try {
364
+ out = send();
365
+ }
366
+ catch (error) {
367
+ lastAt = Date.now();
368
+ return Promise.reject(error);
369
+ }
370
+ if (out && typeof out.then === 'function') {
371
+ return Promise.resolve(out).then(() => {
372
+ lastAt = Date.now();
373
+ sent(item);
374
+ }, (error) => {
375
+ lastAt = Date.now();
376
+ throw error;
377
+ });
378
+ }
379
+ lastAt = Date.now();
380
+ sent(item);
381
+ return Promise.resolve();
382
+ };
383
+ /**
384
+ * Adds an item to the feed. `skip` is read when the item's turn comes: true leaves it unsent and
385
+ * unlisted, with no pause, as for the rest of a reply whose earlier message failed.
386
+ */
387
+ const push = (item, send, skip = () => false) => {
388
+ // Each message's pause is chosen once, when it joins the feed, so a pace that varies never makes it wait twice.
389
+ const pause = item.kind === 'text' && pauseOf ? pauseOf(item.text) : 0;
390
+ const waitFor = () => (pause > 0 && lastAt !== null ? lastAt + pause - Date.now() : 0);
391
+ let run;
392
+ if (tail === null && (skip() || waitFor() <= 0)) {
393
+ run = skip() ? Promise.resolve() : sendNow(item, send);
394
+ }
395
+ else {
396
+ run = (tail ?? Promise.resolve()).then(async () => {
397
+ if (skip())
398
+ return;
399
+ const wait = waitFor();
400
+ if (wait > 0) {
401
+ typing(true);
402
+ await sleep(wait);
403
+ typing(false);
404
+ }
405
+ await sendNow(item, send);
406
+ });
407
+ }
408
+ const settled = run.catch(() => { }).then(() => {
409
+ if (tail === settled)
410
+ tail = null;
411
+ });
412
+ tail = settled;
413
+ return run;
414
+ };
415
+ return { items, push };
416
+ }
417
+ /**
418
+ * One reply's messages in a turn's feed: each one takes its place in the feed the moment it is cut,
419
+ * so no link overtakes it, and once one fails to send, none after it goes out, as `messageStream`
420
+ * stops after a failed send. `settled` resolves once every one went out or was left out, and rejects
421
+ * with the first failure.
422
+ */
423
+ function feedReply(text) {
424
+ const sends = [];
425
+ let failure = null;
426
+ const add = (message) => {
427
+ sends.push(text(message, () => failure !== null).catch((error) => {
428
+ failure ??= { error };
429
+ }));
430
+ };
431
+ const settled = async () => {
432
+ await Promise.all(sends);
433
+ if (failure)
434
+ throw failure.error;
435
+ };
436
+ return { add, settled };
437
+ }
438
+ /** A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. */
439
+ function feedStream(text) {
440
+ const reply = feedReply(text);
441
+ const inner = messageStream({ onMessage: reply.add, pace: false });
442
+ return {
443
+ push: (delta) => inner.push(delta),
444
+ end: async () => {
445
+ await inner.end();
446
+ await reply.settled();
447
+ },
448
+ };
449
+ }
278
450
  /**
279
451
  * The order this purchase's checkout produced: the last checkout's own order, or the newest order
280
452
  * placed since the last checkout, one that did not stop first. Agentcard clears the last checkout when
@@ -351,8 +523,8 @@ export function buyTool(options) {
351
523
  const onProgress = o.onProgress;
352
524
  if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
353
525
  throw new TypeError('Pass earlyCards as true or false.');
354
- const earlyCards = o.earlyCards === true && !!o.sendLink;
355
- const streams = o.stream !== false && (earlyCards || !!onProgress);
526
+ // A call streams when it has someone to tell: a stage for onProgress, or early cards for the links of its turn.
527
+ const earlyCards = o.earlyCards === true;
356
528
  if (o.onResult !== undefined && typeof o.onResult !== 'function') {
357
529
  throw new TypeError('Pass onResult as a function that takes each answer.');
358
530
  }
@@ -370,17 +542,35 @@ export function buyTool(options) {
370
542
  throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
371
543
  const onWorking = o.ack === false ? undefined : o.onWorking;
372
544
  const writeAck = typeof o.ack === 'function' ? o.ack : null;
373
- /** Calls running now, when the last one ended, whether this turn's instant reply is taken, and the template it used last. */
545
+ /** Calls running now, when the last one ended, and the template the instant reply used last. */
374
546
  let running = 0;
375
547
  let lastEndedAt = Number.NEGATIVE_INFINITY;
376
- let turnAcked = false;
377
548
  let lastTemplate = -1;
378
549
  /** Counts the model's turns: the calls of one turn share its number. */
379
550
  let modelTurn = 0;
380
- /** This turn's instant reply while it is being sent: every call of the turn waits for it before it hands over a link. */
381
- let ackOut = null;
382
- /** True once `onWorking` delivered this turn's instant reply, so the model is told not to acknowledge again. */
383
- let ackDelivered = false;
551
+ /** A turn with the tool's own callbacks and nothing collected: the turn a call made straight on the tool belongs to. */
552
+ const toolTurn = (number) => ({
553
+ number,
554
+ acked: false,
555
+ neutral: false,
556
+ picked: null,
557
+ ackOut: null,
558
+ ackDelivered: false,
559
+ sendLink,
560
+ onWorking,
561
+ onResult,
562
+ onProgress,
563
+ links: null,
564
+ results: null,
565
+ pages: null,
566
+ storePages: null,
567
+ });
568
+ /**
569
+ * The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
570
+ * captures its turn when it starts, so a later message opening the next one never changes what a call
571
+ * still running for an earlier message waits for or tells the model.
572
+ */
573
+ let implicit = toolTurn(0);
384
574
  /**
385
575
  * The app's instant reply for the shopper's newest message, waiting for the model's first call on that
386
576
  * message, and the step the last answer asked the shopper for. A call still running for an earlier
@@ -390,40 +580,60 @@ export function buyTool(options) {
390
580
  let lastNextAction = null;
391
581
  /** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
392
582
  let lastReply = null;
393
- /** True for a turn whose message `instantAck` sent no reply for and that picks nothing: a slow call still tells the shopper, in words that name nothing. */
394
- let neutralTurn = false;
395
583
  /** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
396
584
  let shownNames = [];
397
585
  const namesShownProduct = (text) => {
398
586
  const said = productWords(text);
399
587
  return said !== '' && shownNames.some((name) => said.includes(name));
400
588
  };
401
- /** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
402
- const instantAck = (shopperText) => {
589
+ /** 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. */
590
+ let shownStoreNames = [];
591
+ /** True for a pick of a product or a store the shopper saw, by its name. */
592
+ const namesShown = (text) => namesShownProduct(text) || namesStore(text, shownStoreNames);
593
+ /** What a message picks: a product it names (`product`), a store or a place in a list (`pick`), or nothing (null). */
594
+ const pickOf = (text) => namesStore(text, shownStoreNames) ? 'pick' : namesShownProduct(text) ? 'product' : isPickText(text) ? 'pick' : null;
595
+ /** How the shopper's message reads for the instant reply: what it picks, and the reply it gets, or null. */
596
+ const ackOfMessage = (shopperText) => {
403
597
  const text = String(shopperText ?? '');
404
- // A shopper who sends a product's name, as a tap on its card does, picks it: no "looking for" reply.
405
- const pick = namesShownProduct(text) || isPickText(text);
406
- const said = !pick && instantAckOf(text, { nextAction: lastNextAction, asked: lastReply }) !== null;
598
+ // A shopper who sends a product's or a store's name, as a tap on its card does, picks it: no "looking for" reply.
599
+ const picked = pickOf(text);
600
+ const said = !picked && instantAckOf(text, { nextAction: lastNextAction, asked: lastReply }) !== null;
407
601
  // The same rotation onWorking uses, so the next reply comes from another template.
408
602
  if (said)
409
603
  lastTemplate = nextTemplate(lastTemplate);
410
- appAck = { at: Date.now(), said, pick };
411
- return said ? ackAt(lastTemplate, text) : null;
604
+ return { picked, text: said ? ackAt(lastTemplate, text) : null };
605
+ };
606
+ /** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
607
+ const instantAck = (shopperText) => {
608
+ const { picked, text } = ackOfMessage(shopperText);
609
+ appAck = { at: Date.now(), said: text !== null, picked };
610
+ return text;
412
611
  };
413
612
  /**
414
613
  * Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
415
614
  * not gone out, and frees the turn's reply for a later call.
416
615
  */
417
- const holdAck = (ask, neutral) => {
418
- if (!onWorking || turnAcked || (!neutral && (!isNewRequest(ask) || namesShownProduct(ask))))
616
+ const holdAck = (ask, t, step = null) => {
617
+ const onWorking = t.onWorking;
618
+ if (!onWorking || t.acked)
619
+ return null;
620
+ // A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
621
+ const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
622
+ // A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
623
+ // search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
624
+ const neutral = t.neutral || answersQuestion(ask, lastReply);
625
+ if (!slow && !neutral && !isNewRequest(ask))
419
626
  return null;
420
- turnAcked = true;
627
+ t.acked = true;
421
628
  let held = true;
422
629
  const timer = setTimeout(() => {
423
630
  held = false;
424
631
  let text;
425
- // A neutral turn's message held no request safe to read back, so no writer sees it.
426
- if (writeAck && !neutral) {
632
+ if (slow) {
633
+ text = slowStepLine(slow);
634
+ }
635
+ else if (writeAck && !neutral) {
636
+ // A neutral turn's message held no request safe to read back, so no writer sees it.
427
637
  try {
428
638
  text = writeAck(ask);
429
639
  }
@@ -433,17 +643,18 @@ export function buyTool(options) {
433
643
  }
434
644
  else {
435
645
  lastTemplate = nextTemplate(lastTemplate);
436
- // A neutral reply names nothing: the shopper's message held no request safe to read back.
437
- text = ackAt(lastTemplate, neutral ? '' : ask);
646
+ // A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
647
+ // tool is working, and never that it is looking for anything.
648
+ text = neutral ? waitAt(lastTemplate) : ackAt(lastTemplate, ask);
438
649
  }
439
650
  if (typeof text !== 'string' || text.trim() === '') {
440
- turnAcked = false;
651
+ t.acked = false;
441
652
  return;
442
653
  }
443
654
  const message = text.trim();
444
- ackOut = Promise.resolve()
655
+ t.ackOut = Promise.resolve()
445
656
  .then(() => onWorking(message))
446
- .then(() => { ackDelivered = true; }, () => { });
657
+ .then(() => { t.ackDelivered = true; }, () => { });
447
658
  }, ackDelayMs);
448
659
  return {
449
660
  cancel: () => {
@@ -451,7 +662,7 @@ export function buyTool(options) {
451
662
  return;
452
663
  held = false;
453
664
  clearTimeout(timer);
454
- turnAcked = false;
665
+ t.acked = false;
455
666
  },
456
667
  };
457
668
  };
@@ -673,9 +884,10 @@ export function buyTool(options) {
673
884
  /** The store pages this tool sent last, with their conversation, the model turn that sent them, and the search they list. */
674
885
  let lastPages = null;
675
886
  /**
676
- * The store page of each product the model reads, sent once per page. A
677
- * page the last batch in this conversation already sent stays with the
678
- * shopper and goes out no second time, unless a later model turn's reply
887
+ * A card for each product the model reads, with its store page, sent once
888
+ * per product: two dishes on a restaurant's one page are two cards. A card
889
+ * the last batch in this conversation already sent stays with the shopper
890
+ * and goes out no second time, unless a later model turn's reply
679
891
  * presents the products again (the catalog's `presented`): a repeated ask
680
892
  * answered with the same products gets their pages beside it, so every
681
893
  * product the reply names has a card. Calls of one model turn still send a
@@ -684,41 +896,54 @@ export function buyTool(options) {
684
896
  * that lists them again sends none twice. A page whose `sendLink` throws
685
897
  * stays unsent, and the call goes on, so the next answer tries it again.
686
898
  */
687
- const deliverPages = async (result, call) => {
688
- if (!sendLink)
899
+ const deliverPages = async (result, call, send) => {
900
+ if (!send)
689
901
  return result;
690
- const pages = new Map();
902
+ // One card per product the model reads, so a restaurant's dishes, which all carry the store's one page, are a card each.
903
+ const cards = new Map();
691
904
  for (const product of shownProducts(result.products)) {
692
- if (product.url && !pages.has(product.url))
693
- pages.set(product.url, product);
905
+ const key = productCardKey(product);
906
+ if (key && !cards.has(key))
907
+ cards.set(key, product);
694
908
  }
695
909
  // An answer with no store page leaves the last batch as it was.
696
- if (pages.size === 0)
910
+ if (cards.size === 0)
697
911
  return result;
698
- const kept = lastPages?.conversationId === result.conversation_id && (!call.presented || lastPages.turn === call.turn);
699
- const before = kept && lastPages ? lastPages.urls : new Set();
912
+ // An answer that presents its products reads the last batch of its own turn: another turn's batch
913
+ // sent in between, as when the shopper's next message answers first, never makes it send its cards again.
914
+ const last = call.presented && lastPages?.turn !== call.turn && call.owner.pages ? call.owner.pages : lastPages;
915
+ const kept = last?.conversationId === result.conversation_id && (!call.presented || last.turn === call.turn);
916
+ const before = kept && last ? last.keys : new Set();
700
917
  // Agentcard names the search an answer lists by when it ran. An answer that lists fewer of the same
701
- // search's products, such as a pick's, keeps every page that search already sent, so a later answer
918
+ // search's products, such as a pick's, keeps every card that search already sent, so a later answer
702
919
  // that lists them again sends none twice.
703
920
  const catalog = result.envelope?.catalog;
704
921
  const search = typeof catalog?.as_of === 'string' ? catalog.as_of : null;
705
- const sameSearch = kept && search !== null && lastPages?.search === search;
922
+ const sameSearch = kept && search !== null && last?.search === search;
706
923
  const held = new Set(sameSearch ? before : []);
707
- for (const [url, product] of pages) {
708
- if (!before.has(url)) {
924
+ for (const [key, product] of cards) {
925
+ if (!before.has(key)) {
709
926
  try {
710
927
  // The id the model reads and a pick takes, never the store page: the page is `url`.
711
928
  const productId = pickId(product);
712
- await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
929
+ // A flight option has no page at a store: its card carries no url, and its productId picks it.
930
+ await send({ kind: 'product', url: product.url || null, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
713
931
  }
714
932
  catch {
715
933
  continue;
716
934
  }
717
935
  }
718
- held.add(url);
936
+ held.add(key);
719
937
  }
720
- lastPages = { conversationId: result.conversation_id, turn: call.turn, search, urls: held };
721
- return { ...result, products: result.products.map((product) => (product.url && held.has(product.url) ? { ...product, linkSent: true } : product)) };
938
+ lastPages = { conversationId: result.conversation_id, turn: call.turn, search, keys: held };
939
+ call.owner.pages = lastPages;
940
+ return {
941
+ ...result,
942
+ products: result.products.map((product) => {
943
+ const key = productCardKey(product);
944
+ return key && held.has(key) ? { ...product, linkSent: true } : product;
945
+ }),
946
+ };
722
947
  };
723
948
  /** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
724
949
  const earlyPages = (conversationId, asOf, products) => ({ conversation_id: conversationId, envelope: { catalog: { as_of: asOf } }, products });
@@ -731,17 +956,19 @@ export function buyTool(options) {
731
956
  * whose id holds a link is left out, as the model view leaves it out. A page whose `sendLink`
732
957
  * throws stays unsent, and the call goes on.
733
958
  */
734
- const deliverStorePages = async (result, call) => {
735
- if (!sendLink || !result.stores?.length)
959
+ const deliverStorePages = async (result, call, send) => {
960
+ if (!send || !result.stores?.length)
736
961
  return result;
737
- const kept = lastStorePages?.conversationId === result.conversation_id && lastStorePages.turn === call.turn;
738
- const held = new Set(kept && lastStorePages ? lastStorePages.urls : []);
962
+ // A turn reads its own last batch when another turn's replaced it in between, so it never sends a store's page twice.
963
+ const last = lastStorePages?.turn !== call.turn && call.owner.storePages ? call.owner.storePages : lastStorePages;
964
+ const kept = last?.conversationId === result.conversation_id && last.turn === call.turn;
965
+ const held = new Set(kept && last ? last.urls : []);
739
966
  for (const store of shownStores(result.stores)) {
740
967
  if (!store.url)
741
968
  continue;
742
969
  if (!held.has(store.url)) {
743
970
  try {
744
- await sendLink({ kind: 'store', url: store.url, title: store.name, storeId: store.id, etaMinutes: store.etaMinutes, expiresAt: null });
971
+ await send({ kind: 'store', url: store.url, title: store.name, storeId: store.id, etaMinutes: store.etaMinutes, imageUrl: store.imageUrl, expiresAt: null });
745
972
  }
746
973
  catch {
747
974
  continue;
@@ -750,26 +977,31 @@ export function buyTool(options) {
750
977
  held.add(store.url);
751
978
  }
752
979
  lastStorePages = { conversationId: result.conversation_id, turn: call.turn, urls: held };
980
+ call.owner.storePages = lastStorePages;
753
981
  return { ...result, stores: result.stores.map((store) => (store.url && held.has(store.url) ? { ...store, linkSent: true } : store)) };
754
982
  };
755
983
  /** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
756
984
  let pagesInTurn = Promise.resolve();
757
- const deliverPagesInTurn = (result, call) => {
758
- const batch = pagesInTurn.then(() => deliverPages(result, call)).then((delivered) => deliverStorePages(delivered, call));
985
+ const deliverPagesInTurn = (result, call, send) => {
986
+ const batch = pagesInTurn.then(() => deliverPages(result, call, send)).then((delivered) => deliverStorePages(delivered, call, send));
759
987
  pagesInTurn = batch.catch(() => { });
760
988
  return batch;
761
989
  };
762
- /** The Vault or approval link first, because the purchase waits on it, then the store pages. */
763
- const deliver = async (result, call) => {
990
+ /** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
991
+ const deliver = async (result, call, send) => {
764
992
  let delivered = result;
765
- if (result.links.length > 0 && sendLink) {
993
+ if (result.links.length > 0 && send) {
766
994
  for (const link of result.links)
767
- await sendLink({ ...link });
995
+ await send({ ...link });
768
996
  delivered = { ...result, link_sent: true };
769
997
  }
770
- return deliverPagesInTurn(delivered, call);
998
+ return deliverPagesInTurn(delivered, call, send);
771
999
  };
772
- const answer = async (raw) => {
1000
+ /**
1001
+ * One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
1002
+ * from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
1003
+ */
1004
+ const answerIn = async (raw, explicit) => {
773
1005
  const startedAt = Date.now();
774
1006
  const input = parseBuyToolInput(raw);
775
1007
  // A new order is never a confirm: a model that sets both drops the cart and places nothing.
@@ -790,47 +1022,49 @@ export function buyTool(options) {
790
1022
  };
791
1023
  // The first call after `instantAck` opens the turn of the message it answered, whose reply is the
792
1024
  // app's. Any other call that starts while another runs, or right after one ended, is the model
793
- // working through the same turn.
794
- const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
795
- appAck = null;
796
- if (opened) {
797
- // Only a reply the app sent, or a pick, quiets onWorking: a message such as `yes` that starts a
798
- // slow search still gets a reply that names nothing.
799
- turnAcked = opened.said || opened.pick;
800
- neutralTurn = !turnAcked;
801
- ackOut = null;
802
- ackDelivered = opened.said;
803
- modelTurn += 1;
804
- }
805
- else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
806
- turnAcked = false;
807
- neutralTurn = false;
808
- ackOut = null;
809
- ackDelivered = false;
810
- modelTurn += 1;
1025
+ // working through the same turn. A call made through `buy.turn` belongs to that turn alone.
1026
+ if (!explicit) {
1027
+ const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
1028
+ appAck = null;
1029
+ if (opened) {
1030
+ // Only a reply the app sent quiets onWorking. A message such as `yes` that starts a slow search
1031
+ // still gets a reply that names nothing, and a slow pick gets one that says it is on it.
1032
+ implicit = { ...toolTurn(++modelTurn), acked: opened.said, neutral: !opened.said && !opened.picked, picked: opened.picked, ackDelivered: opened.said };
1033
+ }
1034
+ else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
1035
+ implicit = toolTurn(++modelTurn);
1036
+ }
811
1037
  }
812
- const callTurn = modelTurn;
1038
+ // The call keeps the turn it started in, whichever turn opens while it runs.
1039
+ const t = explicit ?? implicit;
1040
+ const callTurn = t.number;
1041
+ const send = senderOf(t);
1042
+ const progressOf = t.onProgress;
1043
+ // A confirm streams as a search does, and only a search's products go out early.
1044
+ const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
1045
+ const early = earlyCards && !!send && !confirm;
813
1046
  running += 1;
814
- // A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
815
- const ack = confirm || pick ? null : holdAck(ask, neutralTurn);
1047
+ // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1048
+ // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1049
+ const ack = holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
816
1050
  try {
817
1051
  let turn = null;
818
1052
  let refusal;
819
1053
  // Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
820
1054
  const handlers = streams
821
1055
  ? {
822
- ...(onProgress ? { onProgress: (progress) => { try {
823
- onProgress(progress);
1056
+ ...(progressOf ? { onProgress: (progress) => { try {
1057
+ progressOf(progress);
824
1058
  }
825
1059
  catch { /* the app's display failing never fails the call */ } } } : {}),
826
- ...(earlyCards && sendLink && !confirm
1060
+ ...(early
827
1061
  ? {
828
1062
  onCatalog: async (update) => {
829
1063
  // Products arrived before the instant reply was due: they are the reply.
830
1064
  ack?.cancel();
831
- await ackOut;
1065
+ await t.ackOut;
832
1066
  // The turn's own search presents its products, as a reply that lists them does.
833
- await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true });
1067
+ await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
834
1068
  },
835
1069
  }
836
1070
  : {}),
@@ -844,18 +1078,23 @@ export function buyTool(options) {
844
1078
  }
845
1079
  ack?.cancel();
846
1080
  const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
847
- await ackOut;
848
- const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true });
849
- const result = ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
1081
+ await t.ackOut;
1082
+ const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1083
+ const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
850
1084
  lastNextAction = result.next_action;
851
1085
  lastReply = result.reply;
852
1086
  // An answer that lists products replaces the names, even when every one is too short to keep.
853
1087
  const shown = shownProducts(result.products);
854
1088
  if (shown.length > 0)
855
1089
  shownNames = shown.map((product) => productWords(product.name)).filter((name) => name.length >= MIN_NAME_LENGTH);
856
- if (onResult) {
1090
+ // An answer that lists stores replaces their names, as one that lists products replaces the products'.
1091
+ const storesShown = shownStores(result.stores);
1092
+ if (storesShown.length > 0)
1093
+ shownStoreNames = storesShown.map((store) => store.name);
1094
+ t.results?.push(result);
1095
+ if (t.onResult) {
857
1096
  try {
858
- await onResult(result);
1097
+ await t.onResult(result);
859
1098
  }
860
1099
  catch {
861
1100
  // The app's display failing never fails the purchase call.
@@ -869,6 +1108,7 @@ export function buyTool(options) {
869
1108
  lastEndedAt = Date.now();
870
1109
  }
871
1110
  };
1111
+ const answer = (raw) => answerIn(raw, null);
872
1112
  /** Checks the model's input, with every link taken out of a refusal. */
873
1113
  const parse = (input) => {
874
1114
  try {
@@ -879,21 +1119,160 @@ export function buyTool(options) {
879
1119
  }
880
1120
  };
881
1121
  /** Every call a framework makes: the model's input, checked, then the model's view of the answer as JSON text. */
882
- const handle = async (args) => {
1122
+ const handlerOf = (answerCall) => async (args) => {
883
1123
  try {
884
- return JSON.stringify(forModel(await answer(parseBuyToolInput(args))));
1124
+ return JSON.stringify(forModel(await answerCall(parseBuyToolInput(args))));
885
1125
  }
886
1126
  catch (error) {
887
1127
  throw forModelError(error);
888
1128
  }
889
1129
  };
890
1130
  const { name, description, input_schema } = buyToolDefinition();
891
- const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel, instantAck };
892
- // JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
1131
+ // JSON skips these, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
893
1132
  const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
894
- return Object.defineProperties(tool, {
895
- definition: unsent(buyToolDefinition()),
896
- inputSchema: unsent(buyToolStandardSchema((error) => forModelError(error).message)),
897
- openai: unsent(openAIBuyTool()),
898
- });
1133
+ /** The fields every framework reads and the methods it calls, around one way to answer a call. */
1134
+ const toolOf = (answerCall, own, hidden) => {
1135
+ const handle = handlerOf(answerCall);
1136
+ const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer: answerCall, forModel, ...own };
1137
+ return Object.defineProperties(tool, {
1138
+ definition: unsent(buyToolDefinition()),
1139
+ inputSchema: unsent(buyToolStandardSchema((error) => forModelError(error).message)),
1140
+ openai: unsent(openAIBuyTool()),
1141
+ ...hidden,
1142
+ });
1143
+ };
1144
+ /**
1145
+ * The turn of one shopper message. It takes the next turn number, so its calls never share a turn
1146
+ * with another message's, and it sends its instant reply at once, as `instantAck` would read it.
1147
+ */
1148
+ const turn = (shopperText, options) => {
1149
+ const own = options ?? {};
1150
+ if (typeof own !== 'object')
1151
+ throw new TypeError('Pass the turn options as an object, or leave them out.');
1152
+ for (const key of ['sendLink', 'onWorking', 'onResult', 'onProgress', 'onItem']) {
1153
+ if (own[key] !== undefined && typeof own[key] !== 'function')
1154
+ throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1155
+ }
1156
+ const onItem = own.onItem;
1157
+ if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
1158
+ throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
1159
+ }
1160
+ // Messages are spaced only for an app that hears each one as it goes out.
1161
+ const pauseOf = paceOf(own.pace);
1162
+ const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
1163
+ // With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
1164
+ const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
1165
+ const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
1166
+ // `ack: false` sends no instant reply, whoever would hear it.
1167
+ const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
1168
+ const state = {
1169
+ ...toolTurn(++modelTurn),
1170
+ sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
1171
+ onWorking: acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1172
+ onResult: own.onResult ?? onResult,
1173
+ onProgress: own.onProgress ?? onProgress,
1174
+ links: [],
1175
+ results: [],
1176
+ };
1177
+ /** A message the app's model wrote: it goes to onItem, after every item before it, and the turn lists it. */
1178
+ const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => onItem?.({ kind: 'text', text: message }), skip);
1179
+ let ack = null;
1180
+ const working = state.onWorking;
1181
+ if (working) {
1182
+ const message = String(shopperText ?? '');
1183
+ const read = ackOfMessage(message);
1184
+ let text = read.text;
1185
+ if (text !== null && writeAck) {
1186
+ try {
1187
+ text = writeAck(message) ?? null;
1188
+ }
1189
+ catch {
1190
+ text = null;
1191
+ }
1192
+ text = typeof text === 'string' && text.trim() !== '' ? text.trim() : null;
1193
+ }
1194
+ if (read.picked) {
1195
+ // A pick of a product or a store the shopper saw gets no "looking for" reply; a slow call says it is on it.
1196
+ state.picked = read.picked;
1197
+ }
1198
+ else if (read.text === null) {
1199
+ // A message that starts no search, such as `yes`: a slow call still tells the shopper, in words that name nothing.
1200
+ state.neutral = true;
1201
+ }
1202
+ else if (text !== null) {
1203
+ ack = text;
1204
+ state.acked = true;
1205
+ let sent;
1206
+ try {
1207
+ sent = working(text);
1208
+ }
1209
+ catch (error) {
1210
+ sent = Promise.reject(error);
1211
+ }
1212
+ // Every call of the turn waits for it before it hands over a link, and a failed send costs only the reply.
1213
+ state.ackOut = Promise.resolve(sent).then(() => { state.ackDelivered = true; }, () => { });
1214
+ }
1215
+ // An ack writer that wrote nothing for a new request leaves the slow call to ask it again, with the model's words.
1216
+ }
1217
+ const links = state.links;
1218
+ const results = state.results;
1219
+ // What Agentcard told the shopper that no history the model ran with holds yet.
1220
+ const kept = client.company ? client.keptMessages(externalUserId) : [];
1221
+ let handed = null;
1222
+ const shopperWords = String(shopperText ?? '');
1223
+ /**
1224
+ * The model's messages for this turn. A kept message whose text the history holds more often than
1225
+ * when the message first went in reached the model on a turn whose history the app saved, so the
1226
+ * client forgets it. One the history lacks, as after a turn that failed before its history was
1227
+ * saved, goes in again.
1228
+ */
1229
+ const messagesFor = (history) => {
1230
+ const held = historyText(history);
1231
+ const saved = [];
1232
+ const fresh = [];
1233
+ for (const message of kept) {
1234
+ const text = modelText(message);
1235
+ if (text === '') {
1236
+ saved.push(message);
1237
+ continue;
1238
+ }
1239
+ // Another order's update can read the same, so only a history holding the text more often than
1240
+ // when this message first went in holds this message.
1241
+ const count = occurrences(held, JSON.stringify(text).slice(1, -1));
1242
+ const before = offeredAt.get(message);
1243
+ if (before !== undefined && count > before) {
1244
+ saved.push(message);
1245
+ }
1246
+ else {
1247
+ if (before === undefined)
1248
+ offeredAt.set(message, count);
1249
+ fresh.push(message);
1250
+ }
1251
+ }
1252
+ if (saved.length > 0)
1253
+ client.forgetMessages(externalUserId, saved);
1254
+ handed = fresh;
1255
+ return turnMessages(history, fresh.map(modelText), shopperWords);
1256
+ };
1257
+ return toolOf((raw) => answerIn(raw, state), {}, {
1258
+ agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
1259
+ messages: { value: messagesFor, enumerable: false },
1260
+ ack: { value: ack, enumerable: false },
1261
+ links: { get: () => [...links], enumerable: false },
1262
+ results: { get: () => [...results], enumerable: false },
1263
+ items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
1264
+ // 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.
1265
+ messageStream: { value: () => feedStream(text), enumerable: false },
1266
+ say: {
1267
+ value: (whole) => {
1268
+ const reply = feedReply(text);
1269
+ for (const message of toMessages(whole))
1270
+ reply.add(message);
1271
+ return reply.settled();
1272
+ },
1273
+ enumerable: false,
1274
+ },
1275
+ });
1276
+ };
1277
+ return toolOf(answer, { instantAck, turn }, {});
899
1278
  }