@agent-cards/commerce-sdk 0.18.4 → 0.18.6
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 +45 -2
- package/dist/action-links.js +1 -0
- package/dist/app-instructions.d.ts +17 -0
- package/dist/app-instructions.generated.d.ts +9 -0
- package/dist/app-instructions.generated.js +10 -0
- package/dist/app-instructions.js +22 -0
- package/dist/buy-tool.d.ts +21 -2
- package/dist/buy-tool.js +150 -67
- package/dist/client.d.ts +84 -11
- package/dist/client.js +101 -15
- package/dist/definition.js +2 -1
- package/dist/direct.d.ts +8 -0
- package/dist/direct.js +13 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/message.d.ts +2 -1
- package/dist/model-view.d.ts +29 -4
- package/dist/model-view.js +93 -13
- package/dist/plan.d.ts +41 -0
- package/dist/plan.js +34 -0
- package/dist/shopper-message.d.ts +12 -0
- package/dist/shopper-message.js +10 -0
- package/dist/store-wait.d.ts +27 -0
- package/dist/store-wait.js +41 -0
- package/dist/trip-ask.d.ts +1 -1
- package/dist/trip-ask.js +4 -4
- package/dist/turn-record.d.ts +2 -2
- package/dist/turn-record.js +3 -3
- package/examples/anthropic.mjs +3 -2
- package/package.json +4 -3
package/dist/buy-tool.js
CHANGED
|
@@ -1,44 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The buy tool: one call that buys for one shopper, in one object that every
|
|
3
|
-
* agent framework takes. `answer` sends the model's input to Agentcard and
|
|
4
|
-
* returns the whole answer for your code, with the status, Agentcard's
|
|
5
|
-
* reply, the cart and its hash, the products and the links. `forModel` turns
|
|
6
|
-
* that answer into what the model may read, with every link taken out, and
|
|
7
|
-
* `run`, `execute` and `handle` return that view as JSON text. A link
|
|
8
|
-
* reaches the shopper through `sendLink`, and each call waits for it before
|
|
9
|
-
* it returns: the Vault and approval links, then the store page of each
|
|
10
|
-
* product the model reads.
|
|
11
|
-
*
|
|
12
|
-
* Agentcard keeps the conversation: every call continues the shopper's open
|
|
13
|
-
* purchase (`resume: true`), and a confirm places the cart Agentcard last
|
|
14
|
-
* showed (`confirm: true`). The tool keeps one thing between calls: the store
|
|
15
|
-
* pages it sent last. Agentcard repeats the last search in each answer for a
|
|
16
|
-
* while, so a list it shows again in the same conversation does not reach
|
|
17
|
-
* the shopper twice. A tool built for each message buys the same way, and
|
|
18
|
-
* sends such a list again.
|
|
19
|
-
*
|
|
20
|
-
* Two answers make the tool wait before it returns. A placement Agentcard is
|
|
21
|
-
* still finishing gets one 12-second pause and one read of the conversation.
|
|
22
|
-
* A turn that outlived Agentcard's budget gets a read every 5 seconds for up
|
|
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.
|
|
33
|
-
*/
|
|
34
1
|
import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
|
|
35
2
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
36
3
|
import { directCall, isProductAsk } from './direct.js';
|
|
37
4
|
import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
38
5
|
import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
|
|
39
6
|
import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
|
|
40
|
-
import { isStale } from './shopper-message.js';
|
|
7
|
+
import { isStale, messageInChat } from './shopper-message.js';
|
|
41
8
|
import { actionLinkId, isActionLink } from './action-links.js';
|
|
9
|
+
import { checkStoreWait, storeWaitFields, withLateCards } from './store-wait.js';
|
|
42
10
|
import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
|
|
43
11
|
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
44
12
|
export function productAction(merchant) {
|
|
@@ -81,7 +49,7 @@ export function handedNote(answer) {
|
|
|
81
49
|
}
|
|
82
50
|
/** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
|
|
83
51
|
const modelText = (message) => stripLinks(String(message.text ?? ''));
|
|
84
|
-
/** How often
|
|
52
|
+
/** How often each chat's history held a message's text when the message first went in to its model, by `threadId` ('' for none). */
|
|
85
53
|
const offeredAt = new WeakMap();
|
|
86
54
|
/** How many times `needle` appears in `haystack`, none overlapping. */
|
|
87
55
|
function occurrences(haystack, needle) {
|
|
@@ -151,6 +119,8 @@ const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shoppe
|
|
|
151
119
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
152
120
|
const ADDRESS_CHANGED = 'The delivery address changed since this cart was built. Ask for the cart again, and Agentcard prices it for the new address.';
|
|
153
121
|
const STORE_PICK_GONE = "The store the shopper picked is no longer among the stores Agentcard found, so no store was chosen. Call this tool again with the shopper's words and store_id null, and Agentcard searches again.";
|
|
122
|
+
/** The most product cards a tool keeps from earlier answers for a tap on an older card. */
|
|
123
|
+
const SHOWN_BEFORE_MAX = 200;
|
|
154
124
|
const PICK_GONE = "The product the shopper picked is no longer among the products Agentcard found, so nothing was added. Call this tool again with the shopper's words and product_id null, and Agentcard searches again.";
|
|
155
125
|
/** One cart line as the order holds it: the product, its variant, its choices and its quantity. Prices are left out. */
|
|
156
126
|
const lineKey = (line) => JSON.stringify([line.product_id ?? String(line.name ?? '').trim().toLowerCase(), line.variant ?? null, [...(line.options ?? [])].sort(), line.qty]);
|
|
@@ -352,6 +322,7 @@ function answerOf(turn) {
|
|
|
352
322
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
353
323
|
cart_building: turn.cart_building,
|
|
354
324
|
choice: turn.choice ?? null,
|
|
325
|
+
plan: turn.plan ?? null,
|
|
355
326
|
envelope: turn.raw,
|
|
356
327
|
};
|
|
357
328
|
}
|
|
@@ -419,6 +390,13 @@ const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:paym
|
|
|
419
390
|
* which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
|
|
420
391
|
* it, so the shopper reads them in order; a message before it goes out at once.
|
|
421
392
|
*/
|
|
393
|
+
/**
|
|
394
|
+
* Agentcard's messages of an answer, as written, that the shopper read in full through the early reply (onWrittenText)
|
|
395
|
+
* before the call ended, by the answer they belong to. `turn.replyAsWritten` hands them back for the turn's last answer and
|
|
396
|
+
* `turn.say` of them sends nothing, so the app shows no second reply and its model is not asked to word one
|
|
397
|
+
* (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model wrote a second recommendation of the same hotels).
|
|
398
|
+
*/
|
|
399
|
+
const readAsWritten = new WeakMap();
|
|
422
400
|
function waitsForAnswer(message) {
|
|
423
401
|
return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
|
|
424
402
|
}
|
|
@@ -767,6 +745,13 @@ export function buyTool(options) {
|
|
|
767
745
|
if (!client.company && externalUserId !== undefined) {
|
|
768
746
|
throw new TypeError("A shopper's own access token already names the shopper. Leave externalUserId out.");
|
|
769
747
|
}
|
|
748
|
+
if (o.threadId !== undefined && (typeof o.threadId !== 'string' || o.threadId.trim() === '' || o.threadId.trim().length > 200)) {
|
|
749
|
+
throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
|
|
750
|
+
}
|
|
751
|
+
const threadId = o.threadId?.trim();
|
|
752
|
+
checkStoreWait(o);
|
|
753
|
+
/** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
|
|
754
|
+
const inThisThread = (message) => messageInChat(message, threadId);
|
|
770
755
|
if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
|
|
771
756
|
throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
|
|
772
757
|
}
|
|
@@ -838,6 +823,9 @@ export function buyTool(options) {
|
|
|
838
823
|
const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
|
|
839
824
|
/** Calls running now, when the last one ended, and the template the instant reply used last. */
|
|
840
825
|
let running = 0;
|
|
826
|
+
// When the newest call of this tool started: a late reply on the conversation answers the newest shopper message, so a call
|
|
827
|
+
// that a newer one followed no longer owns it.
|
|
828
|
+
let newestCallAt = 0;
|
|
841
829
|
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
842
830
|
let lastTemplate = -1;
|
|
843
831
|
/** Counts the model's turns: the calls of one turn share its number. */
|
|
@@ -936,6 +924,9 @@ export function buyTool(options) {
|
|
|
936
924
|
let lastAnswerProducts = [];
|
|
937
925
|
/** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
|
|
938
926
|
let lastTappable = [];
|
|
927
|
+
/** Every product card this conversation's answers showed, newest first, each once: a tap on an older card reads these. */
|
|
928
|
+
let shownBefore = [];
|
|
929
|
+
let shownIn = null;
|
|
939
930
|
let lastAnswerStores = [];
|
|
940
931
|
let previousTurn = null;
|
|
941
932
|
/** 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. */
|
|
@@ -1272,7 +1263,8 @@ export function buyTool(options) {
|
|
|
1272
1263
|
* picks one first. A placement another attempt is still finishing keeps the
|
|
1273
1264
|
* reading going and ends as `placing`, never as the cart. A minute without
|
|
1274
1265
|
* any of them returns Agentcard's own answer, or what the last read showed
|
|
1275
|
-
* while the turn still ran.
|
|
1266
|
+
* while the turn still ran. A turn that ended with a reply of its own
|
|
1267
|
+
* (`latest_reply`) answers with that reply and the products it showed.
|
|
1276
1268
|
*/
|
|
1277
1269
|
const awaitCart = async (base, conversationId, since) => {
|
|
1278
1270
|
const shown = new Set(base.hashes);
|
|
@@ -1307,13 +1299,18 @@ export function buyTool(options) {
|
|
|
1307
1299
|
// conversation and it shows one cart. With carts at two stores `confirm: true` still names the
|
|
1308
1300
|
// cart an earlier answer showed, so none is offered: the shopper's pick goes out as an ask.
|
|
1309
1301
|
const one = conversation.carts.length === 1 ? newest : null;
|
|
1302
|
+
// The reply the turn wrote after its early answer is what the shopper waited for: it goes out in place of the
|
|
1303
|
+
// tool's own line, with the products it showed (conv_054988b998634b16ab4a91d9, 2026-10-30: a stop message
|
|
1304
|
+
// written while the shopper read "one moment" was never shown, and the model was told to call again).
|
|
1305
|
+
const late = conversation.turn_in_progress || newestCallAt > since ? null : conversation.latest_reply;
|
|
1310
1306
|
const answer = {
|
|
1311
1307
|
...base,
|
|
1312
|
-
reply: one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
|
|
1308
|
+
reply: late ? late.text : one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
|
|
1309
|
+
...(late?.catalog ? { products: productsOfCatalog(late.catalog) } : {}),
|
|
1313
1310
|
cart: one,
|
|
1314
1311
|
carts: conversation.carts,
|
|
1315
1312
|
hashes: conversation.carts.map((c) => c.hash),
|
|
1316
|
-
error_code: newest ? null : base.error_code,
|
|
1313
|
+
error_code: newest || late ? null : base.error_code,
|
|
1317
1314
|
// A read replaced the answer the step was named for, and names no step of its own: the reply asks.
|
|
1318
1315
|
next_action: null,
|
|
1319
1316
|
// The early answer judged the pick before the turn finished adding it.
|
|
@@ -1750,6 +1747,16 @@ export function buyTool(options) {
|
|
|
1750
1747
|
let refusal;
|
|
1751
1748
|
/** Each message of Agentcard's answer the app showed through onText, in the order the shopper read them: every one means the shopper read the reply. */
|
|
1752
1749
|
const told = [];
|
|
1750
|
+
/**
|
|
1751
|
+
* Where this call's messages from Agentcard go as they stream: the app's onText, or, for an app that shows the reply as
|
|
1752
|
+
* written, its onMessage once Agentcard says the reply is written (`reply_written`). Until then such an app's
|
|
1753
|
+
* messages are kept in `unsaid`, and a call whose stream never says so shows none of them: the app shows the answer.
|
|
1754
|
+
*/
|
|
1755
|
+
let speak = t.onText;
|
|
1756
|
+
// Only where the cards went out as they streamed (earlyCards): a reply shown earlier than its cards would ask the shopper
|
|
1757
|
+
// to choose from hotels they cannot see yet.
|
|
1758
|
+
const writtenText = early ? t.onWrittenText : undefined;
|
|
1759
|
+
const unsaid = [];
|
|
1753
1760
|
/**
|
|
1754
1761
|
* The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
|
|
1755
1762
|
* and every message after it, so the shopper reads them in the order Agentcard wrote them.
|
|
@@ -1762,7 +1769,7 @@ export function buyTool(options) {
|
|
|
1762
1769
|
let heldTexts = [];
|
|
1763
1770
|
/** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
|
|
1764
1771
|
const showMessage = async (message, texts, conversationId) => {
|
|
1765
|
-
await
|
|
1772
|
+
await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
|
|
1766
1773
|
leadIn = leadsIntoList(message);
|
|
1767
1774
|
told.push(...texts);
|
|
1768
1775
|
};
|
|
@@ -1802,6 +1809,9 @@ export function buyTool(options) {
|
|
|
1802
1809
|
let cardsOut = false;
|
|
1803
1810
|
/** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
|
|
1804
1811
|
const streamedCards = [];
|
|
1812
|
+
/** Whether Agentcard said the reply is written (`reply_written`), and the cards that went out after it: a store that answered late. */
|
|
1813
|
+
let replyWritten = false;
|
|
1814
|
+
const lateCards = [];
|
|
1805
1815
|
let heldCount = null;
|
|
1806
1816
|
/** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
|
|
1807
1817
|
const cardOfId = new Map();
|
|
@@ -1863,6 +1873,23 @@ export function buyTool(options) {
|
|
|
1863
1873
|
// The app's display failing never fails the call.
|
|
1864
1874
|
}
|
|
1865
1875
|
};
|
|
1876
|
+
/** One of Agentcard's messages as it streams, to the app through `speak`. */
|
|
1877
|
+
const streamText = async (text, conversationId) => {
|
|
1878
|
+
// A message that lands before the instant reply was due is the reply; one after it follows it.
|
|
1879
|
+
ack?.cancel();
|
|
1880
|
+
await t.ackOut;
|
|
1881
|
+
// A card ask or a link waits for the answer, which may name a step that comes first, and every message after it waits behind it.
|
|
1882
|
+
if (waiting.length > 0 || waitsForAnswer(text)) {
|
|
1883
|
+
waiting.push({ text, conversationId });
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
try {
|
|
1887
|
+
await showText(text, conversationId);
|
|
1888
|
+
}
|
|
1889
|
+
catch {
|
|
1890
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1866
1893
|
// Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
|
|
1867
1894
|
// answers, and each message and cart to the app as Agentcard writes it.
|
|
1868
1895
|
const handlers = streams
|
|
@@ -1901,13 +1928,19 @@ export function buyTool(options) {
|
|
|
1901
1928
|
// The products past the cards are read after the cards went out, so the first card never waits for them.
|
|
1902
1929
|
const keepMore = () => { if (t.onFound && early)
|
|
1903
1930
|
moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
|
|
1904
|
-
if (!early)
|
|
1931
|
+
if (!early) {
|
|
1932
|
+
// Without early cards nothing goes out here, but a store that answered after the reply still joins the result.
|
|
1933
|
+
if (replyWritten)
|
|
1934
|
+
lateCards.push(...productsOfCatalog(update.catalog));
|
|
1905
1935
|
return;
|
|
1936
|
+
}
|
|
1906
1937
|
// Products arrived before the instant reply was due: they are the reply.
|
|
1907
1938
|
ack?.cancel();
|
|
1908
1939
|
await t.ackOut;
|
|
1909
1940
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
1910
|
-
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1941
|
+
const out = await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1942
|
+
if (replyWritten)
|
|
1943
|
+
lateCards.push(...out.products);
|
|
1911
1944
|
// A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
|
|
1912
1945
|
noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
|
|
1913
1946
|
streamedCards.push(...productsOfCatalog(update.catalog));
|
|
@@ -1928,23 +1961,26 @@ export function buyTool(options) {
|
|
|
1928
1961
|
},
|
|
1929
1962
|
}
|
|
1930
1963
|
: {}),
|
|
1931
|
-
...(t.onText
|
|
1964
|
+
...(t.onText || writtenText
|
|
1932
1965
|
? {
|
|
1933
1966
|
onSegment: async ({ text, conversationId }) => {
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
await t.ackOut;
|
|
1937
|
-
// A card ask or a link waits for the answer, which may name a step that comes first, and every message after it waits behind it.
|
|
1938
|
-
if (waiting.length > 0 || waitsForAnswer(text)) {
|
|
1939
|
-
waiting.push({ text, conversationId });
|
|
1967
|
+
if (!speak) {
|
|
1968
|
+
unsaid.push({ text, conversationId });
|
|
1940
1969
|
return;
|
|
1941
1970
|
}
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1971
|
+
await streamText(text, conversationId);
|
|
1972
|
+
},
|
|
1973
|
+
}
|
|
1974
|
+
: {}),
|
|
1975
|
+
...(early || t.onProducts
|
|
1976
|
+
? {
|
|
1977
|
+
onReplyWritten: async () => {
|
|
1978
|
+
replyWritten = true;
|
|
1979
|
+
if (speak || !writtenText)
|
|
1980
|
+
return;
|
|
1981
|
+
speak = writtenText;
|
|
1982
|
+
for (const message of unsaid.splice(0))
|
|
1983
|
+
await streamText(message.text, message.conversationId);
|
|
1948
1984
|
},
|
|
1949
1985
|
}
|
|
1950
1986
|
: {}),
|
|
@@ -1983,10 +2019,18 @@ export function buyTool(options) {
|
|
|
1983
2019
|
// A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
|
|
1984
2020
|
const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
|
|
1985
2021
|
sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
|
|
2022
|
+
// Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
|
|
2023
|
+
if (threadId)
|
|
2024
|
+
sent = { ...sent, thread_id: threadId };
|
|
2025
|
+
// Every request names how long the reply waits for stores, and what happens to a store that answers after it.
|
|
2026
|
+
sent = { ...sent, ...storeWaitFields(t.storeWait ?? {}, o) };
|
|
1986
2027
|
if (plan && 'local' in plan) {
|
|
1987
2028
|
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1988
2029
|
}
|
|
1989
2030
|
else {
|
|
2031
|
+
// This call now owns the conversation's newest reply: an earlier call still waiting for its late reply gives it up.
|
|
2032
|
+
// A call that threw on its input, or answered from where the purchase stands, sent nothing and takes none.
|
|
2033
|
+
newestCallAt = Math.max(newestCallAt, startedAt);
|
|
1990
2034
|
try {
|
|
1991
2035
|
turn = await client.buy(sent, handlers);
|
|
1992
2036
|
}
|
|
@@ -2003,6 +2047,8 @@ export function buyTool(options) {
|
|
|
2003
2047
|
// Only the answer of the request sent again counts toward what the shopper read.
|
|
2004
2048
|
told.length = 0;
|
|
2005
2049
|
waiting.length = 0;
|
|
2050
|
+
unsaid.length = 0;
|
|
2051
|
+
speak = t.onText;
|
|
2006
2052
|
leads = leadInJoiner();
|
|
2007
2053
|
heldTexts = [];
|
|
2008
2054
|
try {
|
|
@@ -2029,7 +2075,7 @@ export function buyTool(options) {
|
|
|
2029
2075
|
// a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
|
|
2030
2076
|
// before any link of the answer.
|
|
2031
2077
|
let replaced = false;
|
|
2032
|
-
if (waiting.length > 0 &&
|
|
2078
|
+
if (waiting.length > 0 && speak) {
|
|
2033
2079
|
const rewritten = !!turn && settled.reply !== turn.reply;
|
|
2034
2080
|
// Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
|
|
2035
2081
|
const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
|
|
@@ -2055,7 +2101,7 @@ export function buyTool(options) {
|
|
|
2055
2101
|
}
|
|
2056
2102
|
}
|
|
2057
2103
|
}
|
|
2058
|
-
if (
|
|
2104
|
+
if (speak) {
|
|
2059
2105
|
try {
|
|
2060
2106
|
await showLeftLead();
|
|
2061
2107
|
}
|
|
@@ -2068,10 +2114,10 @@ export function buyTool(options) {
|
|
|
2068
2114
|
// A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
|
|
2069
2115
|
// by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
|
|
2070
2116
|
let choiceShown = true;
|
|
2071
|
-
if (
|
|
2117
|
+
if (speak && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
|
|
2072
2118
|
const lines = choiceLines(turn.choice);
|
|
2073
2119
|
try {
|
|
2074
|
-
await
|
|
2120
|
+
await speak(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
|
|
2075
2121
|
}
|
|
2076
2122
|
catch {
|
|
2077
2123
|
// Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
|
|
@@ -2079,8 +2125,14 @@ export function buyTool(options) {
|
|
|
2079
2125
|
}
|
|
2080
2126
|
}
|
|
2081
2127
|
const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
|
|
2128
|
+
// The shopper read the whole answer before the call ended, the late source's message included: the turn's reply is what
|
|
2129
|
+
// they read, and nothing is worded again (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model
|
|
2130
|
+
// wrote a second recommendation of the same hotels).
|
|
2082
2131
|
const answer = withMessages(settled, turn);
|
|
2083
|
-
|
|
2132
|
+
// A late reply read from the conversation presents the products of its own catalog, which the early answer never held.
|
|
2133
|
+
const late = settled.envelope?.latest_reply;
|
|
2134
|
+
const presented = turn?.catalog?.presented === true || (late?.text === settled.reply && late?.catalog?.presented === true);
|
|
2135
|
+
const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented, owner: t }, send);
|
|
2084
2136
|
// A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
|
|
2085
2137
|
if (heldCount && (delivered.products?.length ?? 0) > 0) {
|
|
2086
2138
|
const held = heldCount;
|
|
@@ -2105,11 +2157,15 @@ export function buyTool(options) {
|
|
|
2105
2157
|
: null;
|
|
2106
2158
|
const result = {
|
|
2107
2159
|
...delivered,
|
|
2160
|
+
// The cards of a store that answered after the reply join the answer's, when the answer does not list them already.
|
|
2161
|
+
...(lateCards.length > 0 ? { products: withLateCards(delivered.products, lateCards) } : {}),
|
|
2108
2162
|
...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
|
|
2109
2163
|
...(read ? { shopper_already_read_reply: true } : {}),
|
|
2110
2164
|
...(confirmSent ? { confirm_sent: true } : {}),
|
|
2111
2165
|
...(yesCarried ? { yes_carried: yesCarried } : {}),
|
|
2112
2166
|
};
|
|
2167
|
+
if (read && speak && speak === writtenText)
|
|
2168
|
+
readAsWritten.set(result, told.join('\n\n'));
|
|
2113
2169
|
lastNextAction = result.next_action;
|
|
2114
2170
|
lastReply = result.reply;
|
|
2115
2171
|
// A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
|
|
@@ -2159,6 +2215,13 @@ export function buyTool(options) {
|
|
|
2159
2215
|
}
|
|
2160
2216
|
lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
2161
2217
|
lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
2218
|
+
// A new conversation starts the cards shown before afresh; each card keeps its newest showing, up to SHOWN_BEFORE_MAX.
|
|
2219
|
+
if (result.conversation_id && result.conversation_id !== shownIn)
|
|
2220
|
+
shownBefore = [];
|
|
2221
|
+
if (result.conversation_id)
|
|
2222
|
+
shownIn = result.conversation_id;
|
|
2223
|
+
const fresh = new Set(lastTappable.map((product) => product.productId ?? `name:${product.name}`));
|
|
2224
|
+
shownBefore = [...lastTappable, ...shownBefore.filter((product) => !fresh.has(product.productId ?? `name:${product.name}`))].slice(0, SHOWN_BEFORE_MAX);
|
|
2162
2225
|
lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
|
|
2163
2226
|
t.results?.push(result);
|
|
2164
2227
|
if (t.onResult) {
|
|
@@ -2235,6 +2298,8 @@ export function buyTool(options) {
|
|
|
2235
2298
|
return null;
|
|
2236
2299
|
if (client.company && message.externalUserId !== externalUserId)
|
|
2237
2300
|
return null;
|
|
2301
|
+
if (!inThisThread(message))
|
|
2302
|
+
return null;
|
|
2238
2303
|
if (message.eventId && stepped.has(message.eventId))
|
|
2239
2304
|
return null;
|
|
2240
2305
|
// A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
|
|
@@ -2243,7 +2308,7 @@ export function buyTool(options) {
|
|
|
2243
2308
|
if (message.eventId)
|
|
2244
2309
|
stepped.add(message.eventId);
|
|
2245
2310
|
if (client.company)
|
|
2246
|
-
client.answered(externalUserId, message);
|
|
2311
|
+
client.answered(externalUserId, message, threadId);
|
|
2247
2312
|
return openTurn(nextStepPrompt(message), options, message);
|
|
2248
2313
|
};
|
|
2249
2314
|
const openTurn = (shopperText, options, event) => {
|
|
@@ -2268,6 +2333,7 @@ export function buyTool(options) {
|
|
|
2268
2333
|
if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
|
|
2269
2334
|
throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
|
|
2270
2335
|
const texting = own.textingStyle ?? textingByDefault;
|
|
2336
|
+
checkStoreWait(own, ' to the turn');
|
|
2271
2337
|
const hearText = own.onText ?? o.onText;
|
|
2272
2338
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
2273
2339
|
const pauseOf = paceOf(own.pace);
|
|
@@ -2337,7 +2403,14 @@ export function buyTool(options) {
|
|
|
2337
2403
|
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
2338
2404
|
// an app that passes onItem too hears it once.
|
|
2339
2405
|
onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
|
|
2406
|
+
// An app that shows the reply as written hears it the moment Agentcard says it is written, when the turn then waits for
|
|
2407
|
+
// a slower source's cards: the reply no longer waits for them (chat.opencommerce.sh, 2026-10-29, a hotel reply written
|
|
2408
|
+
// at about 21 s reached the shopper at 36.5 s, with the second supplier's hotels).
|
|
2409
|
+
onWrittenText: !hearText && written && (onItem || onMessage)
|
|
2410
|
+
? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : onMessage?.(text)))
|
|
2411
|
+
: undefined,
|
|
2340
2412
|
texting,
|
|
2413
|
+
storeWait: { storeWaitMs: own.storeWaitMs, lateResults: own.lateResults },
|
|
2341
2414
|
onCart: own.onCart ?? o.onCart,
|
|
2342
2415
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
2343
2416
|
links: [],
|
|
@@ -2430,13 +2503,17 @@ export function buyTool(options) {
|
|
|
2430
2503
|
const results = state.results;
|
|
2431
2504
|
// A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
|
|
2432
2505
|
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
2506
|
+
// A tap names its card by the id its link carried, whatever answer showed it: the pick goes straight with that id.
|
|
2507
|
+
const tappedId = typeof options?.tapped?.productId === 'string' && options.tapped.productId.trim() ? options.tapped.productId.trim() : null;
|
|
2433
2508
|
// A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
|
|
2434
2509
|
// straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
|
|
2435
2510
|
const direct = !directOn || running > 0
|
|
2436
2511
|
? null
|
|
2437
2512
|
: event
|
|
2438
2513
|
? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
|
|
2439
|
-
:
|
|
2514
|
+
: tappedId && String(shopperText ?? '').trim()
|
|
2515
|
+
? { input: { ask: String(shopperText).trim(), confirm: false, new_order: false, product_id: tappedId, store_id: null }, reason: 'pick' }
|
|
2516
|
+
: directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, shownBefore, stores: lastAnswerStores });
|
|
2440
2517
|
// Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
|
|
2441
2518
|
// or the wait line below.
|
|
2442
2519
|
state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
|
|
@@ -2504,7 +2581,7 @@ export function buyTool(options) {
|
|
|
2504
2581
|
if (!event)
|
|
2505
2582
|
previousTurn = state;
|
|
2506
2583
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
2507
|
-
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
2584
|
+
const kept = client.company ? client.keptMessages(externalUserId, threadId).filter(inThisThread) : [];
|
|
2508
2585
|
let handed = null;
|
|
2509
2586
|
const shopperWords = String(shopperText ?? '');
|
|
2510
2587
|
/**
|
|
@@ -2526,18 +2603,20 @@ export function buyTool(options) {
|
|
|
2526
2603
|
// Another order's update can read the same, so only a history holding the text more often than
|
|
2527
2604
|
// when this message first went in holds this message.
|
|
2528
2605
|
const count = occurrences(held, JSON.stringify(text).slice(1, -1));
|
|
2529
|
-
|
|
2606
|
+
// Each chat counts its own history: a message about the shopper alone goes in to every chat's model.
|
|
2607
|
+
const counts = offeredAt.get(message) ?? new Map();
|
|
2608
|
+
const before = counts.get(threadId ?? '');
|
|
2530
2609
|
if (before !== undefined && count > before) {
|
|
2531
2610
|
saved.push(message);
|
|
2532
2611
|
}
|
|
2533
2612
|
else {
|
|
2534
2613
|
if (before === undefined)
|
|
2535
|
-
offeredAt.set(message, count);
|
|
2614
|
+
offeredAt.set(message, counts.set(threadId ?? '', count));
|
|
2536
2615
|
fresh.push(message);
|
|
2537
2616
|
}
|
|
2538
2617
|
}
|
|
2539
2618
|
if (saved.length > 0)
|
|
2540
|
-
client.forgetMessages(externalUserId, saved);
|
|
2619
|
+
client.forgetMessages(externalUserId, saved, threadId);
|
|
2541
2620
|
handed = fresh;
|
|
2542
2621
|
// A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
|
|
2543
2622
|
return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
|
|
@@ -2545,6 +2624,10 @@ export function buyTool(options) {
|
|
|
2545
2624
|
/** Says a whole text as the turn's messages, after every item before it (`say()`). */
|
|
2546
2625
|
const sayWhole = (whole) => {
|
|
2547
2626
|
writesThroughTurn = true;
|
|
2627
|
+
// The reply the shopper already read as Agentcard wrote it (`readAsWritten`), of the turn's last answer: saying it again sends nothing.
|
|
2628
|
+
const read = results.length > 0 ? readAsWritten.get(results[results.length - 1]) : undefined;
|
|
2629
|
+
if (read && whole === read)
|
|
2630
|
+
return releaseForReply();
|
|
2548
2631
|
firstWords(whole);
|
|
2549
2632
|
if (state.modelTurn && /\S/.test(String(whole ?? '')))
|
|
2550
2633
|
state.modelTurn.spoke = true;
|
|
@@ -2613,7 +2696,7 @@ export function buyTool(options) {
|
|
|
2613
2696
|
if (event.eventId)
|
|
2614
2697
|
stepped.delete(event.eventId);
|
|
2615
2698
|
if (client.company)
|
|
2616
|
-
client.unanswered(externalUserId, event);
|
|
2699
|
+
client.unanswered(externalUserId, event, threadId);
|
|
2617
2700
|
},
|
|
2618
2701
|
enumerable: false,
|
|
2619
2702
|
},
|
|
@@ -2628,7 +2711,7 @@ export function buyTool(options) {
|
|
|
2628
2711
|
results: { get: () => [...results], enumerable: false },
|
|
2629
2712
|
direct: { value: direct, enumerable: false },
|
|
2630
2713
|
stopped: { get: () => state.stopped === true, enumerable: false },
|
|
2631
|
-
replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
|
|
2714
|
+
replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? readAsWritten.get(results[results.length - 1]) ?? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
|
|
2632
2715
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
2633
2716
|
// 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.
|
|
2634
2717
|
// A step link held for this message goes out right after its last paragraph, once the stream ends.
|