@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/README.md +4 -4
- package/dist/ack.d.ts +14 -4
- package/dist/ack.js +29 -3
- package/dist/buy-tool.d.ts +16 -7
- package/dist/buy-tool.js +270 -21
- package/dist/client.d.ts +8 -0
- package/dist/client.js +4 -1
- package/dist/definition.js +1 -0
- package/dist/direct.d.ts +11 -1
- package/dist/direct.js +34 -7
- 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')
|
|
@@ -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
|
|
114
|
-
const ACK_DELAY_MS =
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
1627
|
-
const confirmSent = plan ? 'body' in plan &&
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/definition.js
CHANGED
|
@@ -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;
|