@agent-cards/commerce-sdk 0.16.0 → 0.18.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/README.md +21 -20
- package/dist/ack.d.ts +14 -4
- package/dist/ack.js +29 -3
- package/dist/buy-tool.d.ts +28 -7
- package/dist/buy-tool.js +358 -34
- package/dist/client.d.ts +33 -0
- package/dist/client.js +5 -1
- package/dist/definition.js +1 -0
- package/dist/direct.d.ts +11 -1
- package/dist/direct.js +34 -7
- package/dist/index.d.ts +1 -1
- package/dist/messages.d.ts +27 -6
- package/dist/messages.js +68 -8
- package/dist/model-view.d.ts +5 -2
- package/dist/model-view.js +192 -23
- package/dist/sentences.d.ts +13 -0
- package/dist/sentences.js +28 -0
- package/dist/texting.d.ts +2 -0
- package/dist/texting.js +6 -1
- package/package.json +1 -1
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,
|
|
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')
|
|
@@ -75,6 +75,10 @@ export function turnMessages(history, told, shopperText) {
|
|
|
75
75
|
out.push({ role: 'user', content: shopperText });
|
|
76
76
|
return out;
|
|
77
77
|
}
|
|
78
|
+
/** The note a message carries to the model when Agentcard already answered it and left the words to the model (`handled()`). */
|
|
79
|
+
export function handedNote(answer) {
|
|
80
|
+
return `[Agentcard already answered this message with the buy tool. Its answer, for you to tell the shopper in your words, without calling the tool again for it: ${answer}]`;
|
|
81
|
+
}
|
|
78
82
|
/** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
|
|
79
83
|
const modelText = (message) => stripLinks(String(message.text ?? ''));
|
|
80
84
|
/** How often the history held a message's text when the message first went in to the model. */
|
|
@@ -110,8 +114,8 @@ const RESUME_SKEW_MS = 2 * 60_000;
|
|
|
110
114
|
const STOPPED_ORDER = new Set(['failed', 'cancelled']);
|
|
111
115
|
/** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
|
|
112
116
|
const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
|
|
113
|
-
/** How long
|
|
114
|
-
const ACK_DELAY_MS =
|
|
117
|
+
/** How long a turn shows the shopper nothing before the line that says it is on it goes out. */
|
|
118
|
+
const ACK_DELAY_MS = 1_000;
|
|
115
119
|
/** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
|
|
116
120
|
const SAME_TURN_MS = 15_000;
|
|
117
121
|
/** A product name shorter than this, such as `AA`, is too short to tell a pick from a search. */
|
|
@@ -370,7 +374,8 @@ function onlyNextStepLinks(result, next) {
|
|
|
370
374
|
*/
|
|
371
375
|
function withMessages(result, turn) {
|
|
372
376
|
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
373
|
-
|
|
377
|
+
// 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.
|
|
378
|
+
return { ...result, messages: joinLeadIns(sent.flatMap(paragraphsOf)) };
|
|
374
379
|
}
|
|
375
380
|
/** True when the app showed every message of an answer that has one, each as written and in order. */
|
|
376
381
|
function readEvery(told, messages) {
|
|
@@ -532,6 +537,13 @@ function needsCard(link, body) {
|
|
|
532
537
|
envelope: body,
|
|
533
538
|
};
|
|
534
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* One shopper message's turn: the calls the model makes for it share its number, its instant reply and
|
|
542
|
+
* the callbacks its links and answers go to. `links` and `results` collect for a turn `buy.turn` opened,
|
|
543
|
+
* and are null for a call made straight on the tool.
|
|
544
|
+
*/
|
|
545
|
+
/** What a call throws when Agentcard handed the message back to the app's model (`BuyToolTurn.handled`). */
|
|
546
|
+
const HANDED_BACK = Symbol('handed back');
|
|
535
547
|
/** The way a turn hands over a link, or undefined when it has nowhere to send one. */
|
|
536
548
|
function senderOf(t) {
|
|
537
549
|
if (!t.sendLink && !t.links)
|
|
@@ -550,6 +562,8 @@ function senderOf(t) {
|
|
|
550
562
|
*/
|
|
551
563
|
function itemFeed(pauseOf, typing) {
|
|
552
564
|
const items = [];
|
|
565
|
+
// How many items joined the feed, sent or still going out: one means the shopper has something to read.
|
|
566
|
+
let pushed = 0;
|
|
553
567
|
// The item going out now, or null when none is: an item pushed while none is goes out at once.
|
|
554
568
|
let tail = null;
|
|
555
569
|
let lastAt = null;
|
|
@@ -584,6 +598,7 @@ function itemFeed(pauseOf, typing) {
|
|
|
584
598
|
* unlisted, with no pause, as for the rest of a reply whose earlier message failed.
|
|
585
599
|
*/
|
|
586
600
|
const push = (item, send, skip = () => false) => {
|
|
601
|
+
pushed += 1;
|
|
587
602
|
// Each message's pause is chosen once, when it joins the feed, so a pace that varies never makes it wait twice.
|
|
588
603
|
const pause = item.kind === 'text' && pauseOf ? pauseOf(item.text) : 0;
|
|
589
604
|
const waitFor = () => (pause > 0 && lastAt !== null ? lastAt + pause - Date.now() : 0);
|
|
@@ -611,7 +626,7 @@ function itemFeed(pauseOf, typing) {
|
|
|
611
626
|
tail = settled;
|
|
612
627
|
return run;
|
|
613
628
|
};
|
|
614
|
-
return { items, push };
|
|
629
|
+
return { items, push, pushed: () => pushed };
|
|
615
630
|
}
|
|
616
631
|
/**
|
|
617
632
|
* One reply's messages in a turn's feed: each one takes its place in the feed the moment it is cut,
|
|
@@ -748,6 +763,8 @@ export function buyTool(options) {
|
|
|
748
763
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
749
764
|
// A search's cards go out the moment it answers, unless the app waits for the answer that names them.
|
|
750
765
|
const earlyCards = o.earlyCards !== false;
|
|
766
|
+
// Every call streams unless the app waits for whole answers (`stream: false`).
|
|
767
|
+
const streamCalls = o.stream !== false;
|
|
751
768
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
752
769
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
753
770
|
}
|
|
@@ -890,6 +907,8 @@ export function buyTool(options) {
|
|
|
890
907
|
};
|
|
891
908
|
/** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
|
|
892
909
|
let lastAnswerProducts = [];
|
|
910
|
+
/** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
|
|
911
|
+
let lastTappable = [];
|
|
893
912
|
let lastAnswerStores = [];
|
|
894
913
|
let previousTurn = null;
|
|
895
914
|
/** 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 +983,42 @@ export function buyTool(options) {
|
|
|
964
983
|
const holdAck = (ask, t, step = null) => {
|
|
965
984
|
const onWorking = t.onWorking;
|
|
966
985
|
const reacting = t.onReaction;
|
|
967
|
-
|
|
986
|
+
// A turn the app ended (`turn.end()`) holds no acknowledgement for a call it still runs.
|
|
987
|
+
if ((!onWorking && !reacting) || t.acked || t.ended)
|
|
968
988
|
return null;
|
|
989
|
+
const model = t.modelTurn;
|
|
990
|
+
if (model && onWorking && model.writesThrough()) {
|
|
991
|
+
// The app's model called the tool. When it wrote its own line first, that line is the turn's acknowledgement; when
|
|
992
|
+
// its first output is this call, the shopper hears the wait line, no sooner than ackDelayMs after the turn opened.
|
|
993
|
+
model.callStarted = true;
|
|
994
|
+
if (model.spoke || model.shown())
|
|
995
|
+
return null;
|
|
996
|
+
t.acked = true;
|
|
997
|
+
const line = slowStepLine(step === 'confirm' ? 'confirm' : step ?? t.picked ?? 'wait');
|
|
998
|
+
let waiting = true;
|
|
999
|
+
const waitTimer = setTimeout(() => {
|
|
1000
|
+
waiting = false;
|
|
1001
|
+
// The model may have written its own line while this call ran: that line is the turn's acknowledgement, so the
|
|
1002
|
+
// wait line is dropped and the turn's reply is free for a later call.
|
|
1003
|
+
if (model.spoke || model.shown() || t.ended) {
|
|
1004
|
+
t.acked = false;
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
t.waitLineOut = true;
|
|
1008
|
+
t.ackOut = Promise.resolve()
|
|
1009
|
+
.then(() => onWorking(line))
|
|
1010
|
+
.then(() => { t.ackDelivered = true; }, () => { });
|
|
1011
|
+
}, Math.max(0, model.openedAt + ackDelayMs - Date.now()));
|
|
1012
|
+
return {
|
|
1013
|
+
cancel: () => {
|
|
1014
|
+
if (!waiting)
|
|
1015
|
+
return;
|
|
1016
|
+
waiting = false;
|
|
1017
|
+
clearTimeout(waitTimer);
|
|
1018
|
+
t.acked = false;
|
|
1019
|
+
},
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
969
1022
|
// A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
|
|
970
1023
|
const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
|
|
971
1024
|
// A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
|
|
@@ -974,10 +1027,19 @@ export function buyTool(options) {
|
|
|
974
1027
|
// A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
|
|
975
1028
|
if (!reacting && !slow && !neutral && !isNewRequest(ask))
|
|
976
1029
|
return null;
|
|
1030
|
+
// A streamed search's line is Agentcard's (onAck), written from the conversation while it searches: the tool adds no
|
|
1031
|
+
// template of its own, unless the app writes the words.
|
|
1032
|
+
if (!reacting && !slow && !neutral && streamCalls && !writeAck)
|
|
1033
|
+
return null;
|
|
977
1034
|
t.acked = true;
|
|
978
1035
|
let held = true;
|
|
979
1036
|
const timer = setTimeout(() => {
|
|
980
1037
|
held = false;
|
|
1038
|
+
// The app ended the turn while this call ran, as after its model failed: no 👀 and no line follow its error.
|
|
1039
|
+
if (t.ended) {
|
|
1040
|
+
t.acked = false;
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
981
1043
|
// With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
|
|
982
1044
|
if (reacting) {
|
|
983
1045
|
react(t, reacting);
|
|
@@ -1002,7 +1064,7 @@ export function buyTool(options) {
|
|
|
1002
1064
|
lastTemplate = nextTemplate(lastTemplate);
|
|
1003
1065
|
// A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
|
|
1004
1066
|
// tool is working, and never that it is looking for anything.
|
|
1005
|
-
text = neutral ?
|
|
1067
|
+
text = neutral ? slowStepLine('wait') : ackAt(lastTemplate, ask);
|
|
1006
1068
|
}
|
|
1007
1069
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
1008
1070
|
t.acked = false;
|
|
@@ -1623,20 +1685,33 @@ export function buyTool(options) {
|
|
|
1623
1685
|
// A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
|
|
1624
1686
|
// shopper's own yes to this exact cart goes in, for that cart's hash.
|
|
1625
1687
|
const plan = t.event ? await stepPlan(t.event) : null;
|
|
1626
|
-
const
|
|
1627
|
-
|
|
1688
|
+
const handBack = t.handBack ?? null;
|
|
1689
|
+
t.handBack = null;
|
|
1690
|
+
const planned = plan && 'body' in plan ? plan.body : handBack ? { ...body, hand_back: true, last_answer: handBack } : body;
|
|
1691
|
+
const confirmSent = plan ? 'body' in plan && planned.confirm !== undefined : confirm;
|
|
1692
|
+
// Agentcard writes the line that says what it is looking for (onAck) only where the shopper can read it before the
|
|
1693
|
+
// cards and has read nothing yet: a streamed ask, with somewhere to show it, that no reaction, reply or model's words
|
|
1694
|
+
// acknowledged already, in an app that asked for no acknowledgement of its own (`ack: 'none'` or `ack: false` gets
|
|
1695
|
+
// none, there as here) and acknowledges in text: an app that reacts (`ack: 'reaction'`, or an `onReaction` the turn
|
|
1696
|
+
// routes to) reads no line of text, even through `onText`. Every other ask says so, and Agentcard spends no model call on it.
|
|
1697
|
+
const agentcardAcks = streamCalls && ackMode !== 'none' && ackMode !== 'reaction' && !t.onReaction && !t.event && !confirmSent && !pick && !t.acked && (!!t.onWorking || !!t.onText) && (t.agentcardAcks?.(ask) ?? false);
|
|
1698
|
+
/** What goes to Agentcard: the planned request, with `ack: false` when the shopper reads no line of Agentcard's. */
|
|
1699
|
+
let sent = planned;
|
|
1628
1700
|
const callTurn = t.number;
|
|
1629
1701
|
const send = senderOf(t);
|
|
1630
1702
|
const progressOf = t.onProgress;
|
|
1631
1703
|
// Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
|
|
1632
1704
|
// 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
1705
|
// has no handler, and the client asks for the whole answer.
|
|
1634
|
-
const streams =
|
|
1706
|
+
const streams = streamCalls;
|
|
1635
1707
|
const early = earlyCards && !!send && !confirmSent;
|
|
1636
1708
|
running += 1;
|
|
1709
|
+
t.called = true;
|
|
1637
1710
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1638
1711
|
// fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
|
|
1639
1712
|
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1713
|
+
if (ack)
|
|
1714
|
+
(t.holds ??= new Set()).add(ack.cancel);
|
|
1640
1715
|
/** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
|
|
1641
1716
|
let settleLeft = null;
|
|
1642
1717
|
try {
|
|
@@ -1651,16 +1726,51 @@ export function buyTool(options) {
|
|
|
1651
1726
|
const waiting = [];
|
|
1652
1727
|
/** 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
1728
|
let leadIn = false;
|
|
1654
|
-
/**
|
|
1729
|
+
/** A message that leads in with a colon waits for the one after it (leadInJoiner), and `heldTexts` keeps it as Agentcard wrote it. */
|
|
1730
|
+
let leads = leadInJoiner();
|
|
1731
|
+
let heldTexts = [];
|
|
1732
|
+
/** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
|
|
1733
|
+
const showMessage = async (message, texts, conversationId) => {
|
|
1734
|
+
await t.onText(messageForShopper(message, leadIn, t.texting), { conversationId });
|
|
1735
|
+
leadIn = leadsIntoList(message);
|
|
1736
|
+
told.push(...texts);
|
|
1737
|
+
};
|
|
1738
|
+
/**
|
|
1739
|
+
* Shows one of Agentcard's messages through onText. One that ends on a colon, such as `two options came in under $20:`,
|
|
1740
|
+
* goes out with the message after it, so the shopper never reads a bubble ending on a colon.
|
|
1741
|
+
*/
|
|
1655
1742
|
const showText = async (text, conversationId) => {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1743
|
+
const message = leads.add(text);
|
|
1744
|
+
heldTexts.push({ text, conversationId });
|
|
1745
|
+
if (message === null)
|
|
1746
|
+
return;
|
|
1747
|
+
const texts = heldTexts.map((held) => held.text);
|
|
1748
|
+
heldTexts = [];
|
|
1749
|
+
await showMessage(message, texts, conversationId);
|
|
1750
|
+
};
|
|
1751
|
+
/**
|
|
1752
|
+
* 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
|
|
1753
|
+
* sentence, and not at all when that was all of it. It counts as read when anything was, so the model never words again
|
|
1754
|
+
* what the shopper read.
|
|
1755
|
+
*/
|
|
1756
|
+
const showLeftLead = async () => {
|
|
1757
|
+
if (heldTexts.length === 0)
|
|
1758
|
+
return;
|
|
1759
|
+
const rest = leads.end();
|
|
1760
|
+
const texts = heldTexts.map((held) => held.text);
|
|
1761
|
+
const conversationId = heldTexts[0].conversationId;
|
|
1762
|
+
heldTexts = [];
|
|
1763
|
+
if (rest)
|
|
1764
|
+
await showMessage(rest, texts, conversationId);
|
|
1765
|
+
else if (told.length > 0)
|
|
1766
|
+
told.push(...texts);
|
|
1659
1767
|
};
|
|
1660
1768
|
/** The products past the cards of the catalog this call heard last, for the count that follows it. */
|
|
1661
1769
|
let moreFound = null;
|
|
1662
1770
|
/** Whether this call's cards went out, and a count that came before them. */
|
|
1663
1771
|
let cardsOut = false;
|
|
1772
|
+
/** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
|
|
1773
|
+
const streamedCards = [];
|
|
1664
1774
|
let heldCount = null;
|
|
1665
1775
|
/** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
|
|
1666
1776
|
const cardOfId = new Map();
|
|
@@ -1769,6 +1879,7 @@ export function buyTool(options) {
|
|
|
1769
1879
|
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1770
1880
|
// A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
|
|
1771
1881
|
noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
|
|
1882
|
+
streamedCards.push(...productsOfCatalog(update.catalog));
|
|
1772
1883
|
keepMore();
|
|
1773
1884
|
cardsOut = true;
|
|
1774
1885
|
// A count held for these cards goes out now, unless the catalog carries its own, which follows it.
|
|
@@ -1806,6 +1917,24 @@ export function buyTool(options) {
|
|
|
1806
1917
|
},
|
|
1807
1918
|
}
|
|
1808
1919
|
: {}),
|
|
1920
|
+
...(agentcardAcks
|
|
1921
|
+
? {
|
|
1922
|
+
// Agentcard's line for the search, such as `one sec, checking target for la croix`: it goes out as the turn's
|
|
1923
|
+
// instant reply, through onWorking, else as Agentcard's message, and only while the shopper has read nothing
|
|
1924
|
+
// else of the turn. The model then reads `shopper_already_told_looking`.
|
|
1925
|
+
onAck: async ({ text, conversationId }) => {
|
|
1926
|
+
if (t.acked || cardsOut || told.length > 0 || waiting.length > 0)
|
|
1927
|
+
return;
|
|
1928
|
+
ack?.cancel();
|
|
1929
|
+
t.acked = true;
|
|
1930
|
+
const line = text.trim();
|
|
1931
|
+
t.ackOut = Promise.resolve()
|
|
1932
|
+
.then(() => (t.onWorking ? t.onWorking(line) : t.onText?.(line, { conversationId })))
|
|
1933
|
+
.then(() => { t.ackDelivered = true; }, () => { });
|
|
1934
|
+
await t.ackOut;
|
|
1935
|
+
},
|
|
1936
|
+
}
|
|
1937
|
+
: {}),
|
|
1809
1938
|
...(t.onCart
|
|
1810
1939
|
? {
|
|
1811
1940
|
onCart: async ({ cart, carts, conversationId }) => {
|
|
@@ -1820,6 +1949,9 @@ export function buyTool(options) {
|
|
|
1820
1949
|
: {}),
|
|
1821
1950
|
}
|
|
1822
1951
|
: undefined;
|
|
1952
|
+
// A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
|
|
1953
|
+
const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
|
|
1954
|
+
sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
|
|
1823
1955
|
if (plan && 'local' in plan) {
|
|
1824
1956
|
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1825
1957
|
}
|
|
@@ -1830,6 +1962,9 @@ export function buyTool(options) {
|
|
|
1830
1962
|
catch (error) {
|
|
1831
1963
|
refusal = error;
|
|
1832
1964
|
}
|
|
1965
|
+
// Agentcard read the message as your model's: nothing ran, so the call ends here and the turn's model answers it.
|
|
1966
|
+
if (turn?.handed_back)
|
|
1967
|
+
throw HANDED_BACK;
|
|
1833
1968
|
}
|
|
1834
1969
|
// The store was still building the last request's cart: once it is in, the request that was refused goes as it was
|
|
1835
1970
|
// sent, a step's own request too (a saved ask is no longer saved once its step sent it).
|
|
@@ -1837,6 +1972,8 @@ export function buyTool(options) {
|
|
|
1837
1972
|
// Only the answer of the request sent again counts toward what the shopper read.
|
|
1838
1973
|
told.length = 0;
|
|
1839
1974
|
waiting.length = 0;
|
|
1975
|
+
leads = leadInJoiner();
|
|
1976
|
+
heldTexts = [];
|
|
1840
1977
|
try {
|
|
1841
1978
|
turn = await client.buy(sent, handlers);
|
|
1842
1979
|
refusal = undefined;
|
|
@@ -1854,7 +1991,8 @@ export function buyTool(options) {
|
|
|
1854
1991
|
let replaced = false;
|
|
1855
1992
|
if (waiting.length > 0 && t.onText) {
|
|
1856
1993
|
const rewritten = !!turn && settled.reply !== turn.reply;
|
|
1857
|
-
|
|
1994
|
+
// Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
|
|
1995
|
+
const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
|
|
1858
1996
|
for (const message of waiting.splice(0)) {
|
|
1859
1997
|
if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
|
|
1860
1998
|
replaced = true;
|
|
@@ -1877,6 +2015,14 @@ export function buyTool(options) {
|
|
|
1877
2015
|
}
|
|
1878
2016
|
}
|
|
1879
2017
|
}
|
|
2018
|
+
if (t.onText) {
|
|
2019
|
+
try {
|
|
2020
|
+
await showLeftLead();
|
|
2021
|
+
}
|
|
2022
|
+
catch {
|
|
2023
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
1880
2026
|
// Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
|
|
1881
2027
|
// 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
2028
|
// 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 +2038,7 @@ export function buyTool(options) {
|
|
|
1892
2038
|
choiceShown = false;
|
|
1893
2039
|
}
|
|
1894
2040
|
}
|
|
1895
|
-
const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told,
|
|
2041
|
+
const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
|
|
1896
2042
|
const answer = withMessages(settled, turn);
|
|
1897
2043
|
const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1898
2044
|
// 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 +2102,23 @@ export function buyTool(options) {
|
|
|
1956
2102
|
const storesShown = shownStores(result.stores);
|
|
1957
2103
|
if (storesShown.length > 0)
|
|
1958
2104
|
shownStoreNames = storesShown.map((store) => store.name);
|
|
2105
|
+
// 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
|
|
2106
|
+
// read the Burrito's options answered with the Burrito alone, beside the eight menu cards it sent first, and a tap on the
|
|
2107
|
+
// 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
|
|
2108
|
+
// this one, so it picks the product shown in that place (Greptile on #11163).
|
|
2109
|
+
// Each card once, by its id: a call that sends one search's products again (a refreshed catalog) would otherwise hold the
|
|
2110
|
+
// same card twice, and a tap on it would read as two cards named alike (Greptile on #11163).
|
|
2111
|
+
const tappable = [];
|
|
2112
|
+
const held = new Set();
|
|
2113
|
+
for (const product of [...shown, ...shownProducts(streamedCards)]) {
|
|
2114
|
+
const id = pickId(product) ?? `name:${product.name}`;
|
|
2115
|
+
if (held.has(id))
|
|
2116
|
+
continue;
|
|
2117
|
+
held.add(id);
|
|
2118
|
+
tappable.push(product);
|
|
2119
|
+
}
|
|
1959
2120
|
lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
2121
|
+
lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
1960
2122
|
lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
|
|
1961
2123
|
t.results?.push(result);
|
|
1962
2124
|
if (t.onResult) {
|
|
@@ -1973,6 +2135,9 @@ export function buyTool(options) {
|
|
|
1973
2135
|
// A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
|
|
1974
2136
|
await settleLeft?.().catch(() => { });
|
|
1975
2137
|
ack?.cancel();
|
|
2138
|
+
if (ack)
|
|
2139
|
+
t.holds?.delete(ack.cancel);
|
|
2140
|
+
t.dropWaitLine?.();
|
|
1976
2141
|
running -= 1;
|
|
1977
2142
|
lastEndedAt = Date.now();
|
|
1978
2143
|
}
|
|
@@ -2152,7 +2317,29 @@ export function buyTool(options) {
|
|
|
2152
2317
|
return Promise.resolve();
|
|
2153
2318
|
};
|
|
2154
2319
|
/** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
|
|
2155
|
-
|
|
2320
|
+
let modelWrote = false;
|
|
2321
|
+
/** 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. */
|
|
2322
|
+
let wordsBeforeCall = null;
|
|
2323
|
+
const firstWords = (words) => {
|
|
2324
|
+
if (wordsBeforeCall === null && /\S/.test(String(words ?? '')))
|
|
2325
|
+
wordsBeforeCall = !state.called;
|
|
2326
|
+
};
|
|
2327
|
+
const text = (message, skip) => {
|
|
2328
|
+
const first = !modelWrote;
|
|
2329
|
+
modelWrote = true;
|
|
2330
|
+
const show = (left) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), left);
|
|
2331
|
+
// The wait line already told the shopper the turn is on it: the model's own acknowledgement, its first message
|
|
2332
|
+
// written before any call, such as `on it, looking for a dryer under $20`, would be a second line, so it stays in
|
|
2333
|
+
// the model's own history and never reaches the shopper. A first message that asks, answers or says what a search
|
|
2334
|
+
// came to still goes out, and so does every message after it. (2026-10-27: "dryer under 20" read `one moment`,
|
|
2335
|
+
// then `on it, looking for a dryer under $20`.)
|
|
2336
|
+
// A wait line the app failed to send reached nobody (`ackDelivered`), so the model's own line goes out in its
|
|
2337
|
+
// place, in the feed's order: a failed send costs that line alone, never both.
|
|
2338
|
+
if (first && state.waitLineOut && (wordsBeforeCall ?? !state.called) && readsAsAckLine(message)) {
|
|
2339
|
+
return show(() => state.ackDelivered || (skip?.() ?? false));
|
|
2340
|
+
}
|
|
2341
|
+
return show(skip);
|
|
2342
|
+
};
|
|
2156
2343
|
let ack = null;
|
|
2157
2344
|
const working = state.onWorking;
|
|
2158
2345
|
const reacting = state.onReaction;
|
|
@@ -2182,7 +2369,9 @@ export function buyTool(options) {
|
|
|
2182
2369
|
// A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
|
|
2183
2370
|
react(state, reacting);
|
|
2184
2371
|
}
|
|
2185
|
-
else if (text !== null && working) {
|
|
2372
|
+
else if (text !== null && working && (writeAck || !streamCalls)) {
|
|
2373
|
+
// A streamed search's line is Agentcard's to write (onAck), from the conversation, while it searches; the
|
|
2374
|
+
// tool's template goes out at once only for an answer the app waits for whole, or in the app's own words.
|
|
2186
2375
|
ack = text;
|
|
2187
2376
|
state.acked = true;
|
|
2188
2377
|
let sent;
|
|
@@ -2207,7 +2396,71 @@ export function buyTool(options) {
|
|
|
2207
2396
|
? null
|
|
2208
2397
|
: event
|
|
2209
2398
|
? { 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 });
|
|
2399
|
+
: directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, stores: lastAnswerStores });
|
|
2400
|
+
// Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
|
|
2401
|
+
// or the wait line below.
|
|
2402
|
+
state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
|
|
2403
|
+
/** Says the turn is on it, once, in fixed words that name nothing the shopper said, unless something went out first. */
|
|
2404
|
+
const sayWaiting = (line) => {
|
|
2405
|
+
if (state.acked || state.ended || feed.pushed() > 0 || !writesThroughTurn)
|
|
2406
|
+
return;
|
|
2407
|
+
state.acked = true;
|
|
2408
|
+
state.waitLineOut = true;
|
|
2409
|
+
state.ackOut = Promise.resolve()
|
|
2410
|
+
.then(() => working(line))
|
|
2411
|
+
.then(() => { state.ackDelivered = true; }, () => { });
|
|
2412
|
+
};
|
|
2413
|
+
// Only where the tool sees what the shopper read: the app writes the model's words through the turn.
|
|
2414
|
+
if (!event && working && !reacting && direct !== null && direct.reason !== 'product_ask') {
|
|
2415
|
+
// A pick, a confirm or an answer the turn sends straight that has shown nothing after ackDelayMs says it is on it. A
|
|
2416
|
+
// product ask hears Agentcard's line (onAck).
|
|
2417
|
+
const waitTimer = setTimeout(() => sayWaiting(slowStepLine(direct.reason === 'confirm' ? 'confirm' : state.picked ?? 'wait')), ackDelayMs);
|
|
2418
|
+
waitTimer.unref?.();
|
|
2419
|
+
// A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed pick.
|
|
2420
|
+
state.dropWaitLine = () => clearTimeout(waitTimer);
|
|
2421
|
+
}
|
|
2422
|
+
else if (!event && working && !reacting && direct !== null) {
|
|
2423
|
+
// A search the turn sends straight hears Agentcard's line (onAck). When that line never comes, as when Agentcard's
|
|
2424
|
+
// model writes none or its line is dropped, and the shopper has seen nothing, no line and no card, by twice
|
|
2425
|
+
// ackDelayMs, the wait line goes out then, the same rule as a model that has written nothing and called nothing.
|
|
2426
|
+
// Once it went out, a line of Agentcard's that comes later is dropped (onAck reads `acked`), so the shopper reads one
|
|
2427
|
+
// line. A call that already answered leaves the turn to its reply. (2026-10-27: "i want one called ardor" showed
|
|
2428
|
+
// nothing for 9.1 s.)
|
|
2429
|
+
const waitTimer = setTimeout(() => {
|
|
2430
|
+
if (results.length === 0)
|
|
2431
|
+
sayWaiting(slowStepLine('wait'));
|
|
2432
|
+
}, 2 * ackDelayMs);
|
|
2433
|
+
waitTimer.unref?.();
|
|
2434
|
+
// A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed search.
|
|
2435
|
+
state.dropWaitLine = () => clearTimeout(waitTimer);
|
|
2436
|
+
}
|
|
2437
|
+
else if (!event && working && !reacting && direct === null) {
|
|
2438
|
+
// The app's model runs this turn, and its first output decides: its own line first means no wait line; a call first
|
|
2439
|
+
// gets the wait line from that call (holdAck); and a model that has written nothing and called nothing after twice
|
|
2440
|
+
// ackDelayMs gets it then.
|
|
2441
|
+
const model = { openedAt: Date.now(), spoke: false, callStarted: false, writesThrough: () => writesThroughTurn, shown: () => feed.pushed() > 0 };
|
|
2442
|
+
state.modelTurn = model;
|
|
2443
|
+
const waitTimer = setTimeout(() => {
|
|
2444
|
+
if (!model.spoke && !model.callStarted)
|
|
2445
|
+
sayWaiting(slowStepLine(state.picked ?? 'wait'));
|
|
2446
|
+
}, 2 * ackDelayMs);
|
|
2447
|
+
waitTimer.unref?.();
|
|
2448
|
+
// `turn.end()`, as when the app's model failed before it wrote or called anything, stops it: no `one moment` follows
|
|
2449
|
+
// the app's own error. A call that ends stops it too, since a call started means the timer would send nothing.
|
|
2450
|
+
state.dropWaitLine = () => clearTimeout(waitTimer);
|
|
2451
|
+
}
|
|
2452
|
+
// What Agentcard last showed and asked when the turn opened, for `handled()`: its call changes them once it answers.
|
|
2453
|
+
const lastAnswer = {
|
|
2454
|
+
answered,
|
|
2455
|
+
next_action: lastNextAction ?? null,
|
|
2456
|
+
asked: lastReply ?? null,
|
|
2457
|
+
products: lastAnswerProducts.map((product) => ({ product_id: product.productId, name: product.name })),
|
|
2458
|
+
stores: lastAnswerStores.map((store) => ({ store_id: store.storeId, name: store.name })),
|
|
2459
|
+
};
|
|
2460
|
+
const openedWhileRunning = running > 0;
|
|
2461
|
+
const openedNextAction = lastNextAction;
|
|
2462
|
+
const openedProducts = lastAnswerProducts;
|
|
2463
|
+
const openedStores = lastAnswerStores;
|
|
2211
2464
|
if (!event)
|
|
2212
2465
|
previousTurn = state;
|
|
2213
2466
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
@@ -2246,9 +2499,70 @@ export function buyTool(options) {
|
|
|
2246
2499
|
if (saved.length > 0)
|
|
2247
2500
|
client.forgetMessages(externalUserId, saved);
|
|
2248
2501
|
handed = fresh;
|
|
2249
|
-
|
|
2502
|
+
// A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
|
|
2503
|
+
return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
|
|
2504
|
+
};
|
|
2505
|
+
/** Says a whole text as the turn's messages, after every item before it (`say()`). */
|
|
2506
|
+
const sayWhole = (whole) => {
|
|
2507
|
+
writesThroughTurn = true;
|
|
2508
|
+
firstWords(whole);
|
|
2509
|
+
if (state.modelTurn && /\S/.test(String(whole ?? '')))
|
|
2510
|
+
state.modelTurn.spoke = true;
|
|
2511
|
+
const reply = feedReply(text);
|
|
2512
|
+
for (const message of toMessages(whole))
|
|
2513
|
+
reply.add(message);
|
|
2514
|
+
const released = releaseForReply();
|
|
2515
|
+
released.catch(() => { });
|
|
2516
|
+
return reply.settled().then(() => released);
|
|
2517
|
+
};
|
|
2518
|
+
/** Agentcard's answer, as the model reads it, when `handled()` left the words to the model. */
|
|
2519
|
+
let handedAnswer = null;
|
|
2520
|
+
let handledRun = null;
|
|
2521
|
+
/**
|
|
2522
|
+
* One call to Agentcard with the shopper's message (`BuyToolTurn.handled`). A pick by its id or a yes to the total the
|
|
2523
|
+
* tool reads itself goes as that call; every other message goes as its words, with `hand_back`, and Agentcard decides.
|
|
2524
|
+
*/
|
|
2525
|
+
const runHandled = async () => {
|
|
2526
|
+
const words = String(shopperText ?? '');
|
|
2527
|
+
if (event || openedWhileRunning || words.trim() === '')
|
|
2528
|
+
return false;
|
|
2529
|
+
const local = directCall(words, { answered, nextAction: openedNextAction, asked: lastAnswer.asked, products: openedProducts, stores: openedStores });
|
|
2530
|
+
const exact = !!local && (local.input.confirm || local.input.product_id !== null || local.input.store_id !== null);
|
|
2531
|
+
const input = exact ? local.input : { ask: words, confirm: false, new_order: false, product_id: null, store_id: null };
|
|
2532
|
+
if (!exact) {
|
|
2533
|
+
state.handBack = lastAnswer;
|
|
2534
|
+
// Agentcard writes the line that says what it is looking for on a search this call starts, as on a search the turn
|
|
2535
|
+
// sends straight (`direct`); a message it hands back gets none, since nothing runs.
|
|
2536
|
+
state.agentcardAcks = (ask) => ask.trim() === words.trim();
|
|
2537
|
+
}
|
|
2538
|
+
let result;
|
|
2539
|
+
try {
|
|
2540
|
+
result = await answerIn(input, state);
|
|
2541
|
+
}
|
|
2542
|
+
catch (error) {
|
|
2543
|
+
state.handBack = null;
|
|
2544
|
+
if (error === HANDED_BACK)
|
|
2545
|
+
return false;
|
|
2546
|
+
throw error;
|
|
2547
|
+
}
|
|
2548
|
+
if (result.shopper_already_read_reply === true)
|
|
2549
|
+
return true;
|
|
2550
|
+
const reply = replyAsWritten(result, { textingStyle: texting });
|
|
2551
|
+
// The reply goes out where the app hears the turn's messages: onItem or onMessage, else onText. With none of them, the
|
|
2552
|
+
// shopper would read nothing, so the app's model words the answer instead.
|
|
2553
|
+
if (reply !== null && (onItem || onMessage)) {
|
|
2554
|
+
await sayWhole(reply);
|
|
2555
|
+
return true;
|
|
2556
|
+
}
|
|
2557
|
+
if (reply !== null && state.onText) {
|
|
2558
|
+
await state.onText(reply, { conversationId: result.conversation_id });
|
|
2559
|
+
return true;
|
|
2560
|
+
}
|
|
2561
|
+
handedAnswer = JSON.stringify(forModel(result));
|
|
2562
|
+
return false;
|
|
2250
2563
|
};
|
|
2251
2564
|
return toolOf((raw) => answerIn(raw, state), {}, {
|
|
2565
|
+
handled: { value: () => (handledRun ??= runHandled()), enumerable: false },
|
|
2252
2566
|
agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
|
|
2253
2567
|
event: { value: event, enumerable: false },
|
|
2254
2568
|
release: {
|
|
@@ -2262,6 +2576,17 @@ export function buyTool(options) {
|
|
|
2262
2576
|
},
|
|
2263
2577
|
enumerable: false,
|
|
2264
2578
|
},
|
|
2579
|
+
end: {
|
|
2580
|
+
value: () => {
|
|
2581
|
+
state.ended = true;
|
|
2582
|
+
state.dropWaitLine?.();
|
|
2583
|
+
// The acknowledgement a call still running holds, a 👀 or a line for a slow pick, confirm or answer, is cancelled too.
|
|
2584
|
+
for (const cancel of state.holds ?? [])
|
|
2585
|
+
cancel();
|
|
2586
|
+
state.holds?.clear();
|
|
2587
|
+
},
|
|
2588
|
+
enumerable: false,
|
|
2589
|
+
},
|
|
2265
2590
|
messages: { value: messagesFor, enumerable: false },
|
|
2266
2591
|
ack: { value: ack, enumerable: false },
|
|
2267
2592
|
reaction: { get: () => state.reaction, enumerable: false },
|
|
@@ -2275,22 +2600,21 @@ export function buyTool(options) {
|
|
|
2275
2600
|
messageStream: {
|
|
2276
2601
|
value: () => {
|
|
2277
2602
|
writesThroughTurn = true;
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
released.catch(() => { });
|
|
2290
|
-
return reply.settled().then(() => released);
|
|
2603
|
+
const stream = feedStream(text, releaseForReply);
|
|
2604
|
+
return {
|
|
2605
|
+
push: (delta) => {
|
|
2606
|
+
firstWords(delta);
|
|
2607
|
+
// The model's first words: its own line is the turn's acknowledgement, so no wait line follows.
|
|
2608
|
+
if (state.modelTurn && /\S/.test(String(delta ?? '')))
|
|
2609
|
+
state.modelTurn.spoke = true;
|
|
2610
|
+
stream.push(delta);
|
|
2611
|
+
},
|
|
2612
|
+
end: () => stream.end(),
|
|
2613
|
+
};
|
|
2291
2614
|
},
|
|
2292
2615
|
enumerable: false,
|
|
2293
2616
|
},
|
|
2617
|
+
say: { value: sayWhole, enumerable: false },
|
|
2294
2618
|
});
|
|
2295
2619
|
};
|
|
2296
2620
|
return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
|