@agent-cards/commerce-sdk 0.13.0 → 0.15.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 +45 -4
- package/dist/ack.d.ts +24 -0
- package/dist/ack.js +8 -8
- package/dist/buy-tool.d.ts +130 -13
- package/dist/buy-tool.js +347 -17
- package/dist/client.d.ts +132 -3
- package/dist/client.js +176 -17
- package/dist/definition.js +1 -0
- package/dist/direct.d.ts +50 -0
- package/dist/direct.js +237 -0
- package/dist/events.d.ts +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/model-view.d.ts +28 -3
- package/dist/model-view.js +150 -6
- package/dist/shopper-message.d.ts +2 -2
- package/package.json +9 -1
package/dist/buy-tool.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
|
|
35
35
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
36
|
+
import { directCall } from './direct.js';
|
|
36
37
|
import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
37
38
|
import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
|
|
38
39
|
import { isStale } from './shopper-message.js';
|
|
@@ -197,6 +198,11 @@ export function productsOf(turn) {
|
|
|
197
198
|
return turn.catalog ? productsOfCatalog(turn.catalog) : [];
|
|
198
199
|
}
|
|
199
200
|
/** The products of a search, as the tool reads them. */
|
|
201
|
+
/** A product's card as `sendLink` hears it: the id the model reads and a pick takes, never the store page, which is `url`. A flight option has no page at a store: its card carries no url, and its productId picks it. */
|
|
202
|
+
function productLink(product) {
|
|
203
|
+
const productId = pickId(product);
|
|
204
|
+
return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null, ...(product.availability ? { availability: product.availability } : {}) };
|
|
205
|
+
}
|
|
200
206
|
function productsOfCatalog(catalog) {
|
|
201
207
|
return (catalog.items ?? [])
|
|
202
208
|
.filter((item) => item.name)
|
|
@@ -214,6 +220,7 @@ function productsOfCatalog(catalog) {
|
|
|
214
220
|
url: item.url ?? null,
|
|
215
221
|
action: productAction(catalog.merchant),
|
|
216
222
|
linkSent: false,
|
|
223
|
+
...(item.availability ? { availability: item.availability } : {}),
|
|
217
224
|
}));
|
|
218
225
|
}
|
|
219
226
|
/** The stores an answer presents, as the tool reads them. */
|
|
@@ -299,9 +306,67 @@ function withMessages(result, turn) {
|
|
|
299
306
|
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
300
307
|
return { ...result, messages: sent.flatMap(toMessages) };
|
|
301
308
|
}
|
|
309
|
+
/** True when the app showed every message of an answer that has one, each as written and in order. */
|
|
310
|
+
function readEvery(told, messages) {
|
|
311
|
+
const written = messages.map((message) => message.trim()).filter((message) => message !== '');
|
|
312
|
+
return written.length > 0 && written.length === told.length && written.every((message, i) => message === told[i].trim());
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The steps the shopper answers in the chat, none of which has a link, and the line that asks the shopper for one when
|
|
316
|
+
* a reply asks for something else instead. Each is written for the shopper, as Agentcard's messages are: an app that
|
|
317
|
+
* shows Agentcard's messages as they stream shows it in place of the ask it replaces.
|
|
318
|
+
*/
|
|
319
|
+
const CHAT_STEP_ASKS = {
|
|
320
|
+
shipping_details: "What's the shipping address for this order?",
|
|
321
|
+
guest_details: 'What name, email and phone should it go under?',
|
|
322
|
+
verification_code: "What's the verification code the store just sent you?",
|
|
323
|
+
};
|
|
324
|
+
/** Every web address in a text, with or without its scheme. */
|
|
325
|
+
const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi;
|
|
326
|
+
/** An ask for a payment card: "add your card", "save a card", "add a payment card". "Added the gift card" is a cart line. */
|
|
327
|
+
const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:payment|credit|debit)\s+)?card\b/i;
|
|
328
|
+
/**
|
|
329
|
+
* Whether a streamed message waits for the answer before the shopper reads it: it asks for a card or points at a link,
|
|
330
|
+
* which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
|
|
331
|
+
* it, so the shopper reads them in order; a message before it goes out at once.
|
|
332
|
+
*/
|
|
333
|
+
function waitsForAnswer(message) {
|
|
334
|
+
return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The answer with its reply asking for the step Agentcard named, when that step is answered in the chat (an address, a
|
|
338
|
+
* guest's details, a code) and a paragraph of the reply asks for a card, or sends the shopper to a link the answer does
|
|
339
|
+
* not hand over. Agentcard holds the add-card link back while the address comes first, yet its reply can still ask for
|
|
340
|
+
* the card, with the link pasted in or with none: every link is taken out of a reply, so the shopper read "add your card
|
|
341
|
+
* to place the order:" and no link came (2026-10-26, Commerce SDK playground, a Walmart cart with no shipping address).
|
|
342
|
+
* The paragraph gives way to a line that asks the shopper for the named step, so the reply is no longer Agentcard's as
|
|
343
|
+
* written and the model words it. A streamed message that asks for a card or points at a link waits for the answer
|
|
344
|
+
* (`waitsForAnswer`), so an app showing Agentcard's messages as they stream shows that line in its place. A link to a product, store or cart line the answer lists is not held back, and leaves the reply
|
|
345
|
+
* as it is, as does a cart line about a card that is a product ("added the gift card").
|
|
346
|
+
*/
|
|
347
|
+
function askingForNamedStep(result, next) {
|
|
348
|
+
const ask = next ? CHAT_STEP_ASKS[next] : undefined;
|
|
349
|
+
if (!ask || typeof result.reply !== 'string')
|
|
350
|
+
return result;
|
|
351
|
+
const known = [
|
|
352
|
+
...result.links.map((link) => link.url),
|
|
353
|
+
...result.products.map((product) => product.url),
|
|
354
|
+
...result.stores.map((store) => store.url),
|
|
355
|
+
...result.carts.flatMap((c) => c.items.map((line) => line.url)),
|
|
356
|
+
...(result.cart?.items.map((line) => line.url) ?? []),
|
|
357
|
+
].filter((url) => typeof url === 'string' && url !== '');
|
|
358
|
+
// The answer hands over no add-card link beside a step that comes first (onlyNextStepLinks), so a card ask is a dead end.
|
|
359
|
+
const held = (paragraph) => CARD_ASK.test(paragraph) ||
|
|
360
|
+
(paragraph.match(URL_IN_TEXT) ?? []).some((url) => !known.some((page) => page === url || page.startsWith(url) || url.startsWith(page)));
|
|
361
|
+
const paragraphs = result.reply.split(/\n\s*\n/);
|
|
362
|
+
if (!paragraphs.some(held))
|
|
363
|
+
return result;
|
|
364
|
+
const kept = paragraphs.filter((paragraph) => !held(paragraph) && paragraph.trim() !== '');
|
|
365
|
+
return { ...result, reply: [...kept, ask].join('\n\n') };
|
|
366
|
+
}
|
|
302
367
|
/** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
|
|
303
368
|
function resultOfTurn(turn) {
|
|
304
|
-
return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
|
|
369
|
+
return askingForNamedStep(onlyNextStepLinks(resultOfStatus(turn), turn.next_action), turn.next_action);
|
|
305
370
|
}
|
|
306
371
|
/** A purchase answer, read for its status, with every link the answer carries. */
|
|
307
372
|
function resultOfStatus(turn) {
|
|
@@ -600,13 +665,23 @@ export function buyTool(options) {
|
|
|
600
665
|
const sendLink = o.sendLink;
|
|
601
666
|
if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
|
|
602
667
|
throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
|
|
668
|
+
if (o.onProducts !== undefined && typeof o.onProducts !== 'function')
|
|
669
|
+
throw new TypeError('Pass onProducts as a function that takes the products a search found.');
|
|
670
|
+
if (o.onText !== undefined && typeof o.onText !== 'function')
|
|
671
|
+
throw new TypeError('Pass onText as a function that takes each message Agentcard writes.');
|
|
672
|
+
if (o.onCart !== undefined && typeof o.onCart !== 'function')
|
|
673
|
+
throw new TypeError('Pass onCart as a function that takes the carts each time a call builds or changes one.');
|
|
603
674
|
if (o.stream !== undefined && o.stream !== false)
|
|
604
675
|
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
605
676
|
const onProgress = o.onProgress;
|
|
677
|
+
for (const key of ['onStatus', 'onFound', 'onAvailability']) {
|
|
678
|
+
if (o[key] !== undefined && typeof o[key] !== 'function')
|
|
679
|
+
throw new TypeError(`Pass ${key} as a function, or leave it out.`);
|
|
680
|
+
}
|
|
606
681
|
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
607
682
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
608
|
-
// A
|
|
609
|
-
const earlyCards = o.earlyCards
|
|
683
|
+
// A search's cards go out the moment it answers, unless the app waits for the answer that names them.
|
|
684
|
+
const earlyCards = o.earlyCards !== false;
|
|
610
685
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
611
686
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
612
687
|
}
|
|
@@ -614,6 +689,9 @@ export function buyTool(options) {
|
|
|
614
689
|
if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
|
|
615
690
|
throw new TypeError('Pass replyAsWritten as true or false.');
|
|
616
691
|
const writtenByDefault = o.replyAsWritten === true;
|
|
692
|
+
if (o.direct !== undefined && typeof o.direct !== 'boolean')
|
|
693
|
+
throw new TypeError('Pass direct as true or false.');
|
|
694
|
+
const directOn = o.direct === true;
|
|
617
695
|
const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
618
696
|
if (typeof wait !== 'function')
|
|
619
697
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
@@ -666,6 +744,12 @@ export function buyTool(options) {
|
|
|
666
744
|
tell: o.onWorking,
|
|
667
745
|
onResult,
|
|
668
746
|
onProgress,
|
|
747
|
+
onStatus: o.onStatus,
|
|
748
|
+
onFound: o.onFound,
|
|
749
|
+
onAvailability: o.onAvailability,
|
|
750
|
+
onProducts: o.onProducts,
|
|
751
|
+
onText: o.onText,
|
|
752
|
+
onCart: o.onCart,
|
|
669
753
|
links: null,
|
|
670
754
|
results: null,
|
|
671
755
|
pages: null,
|
|
@@ -689,6 +773,11 @@ export function buyTool(options) {
|
|
|
689
773
|
let lastReply = null;
|
|
690
774
|
/** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
|
|
691
775
|
let lastCartCount = 0;
|
|
776
|
+
/**
|
|
777
|
+
* True when the last answer was a checkout that ended with no order and no cart waiting (`declined`): a new order then
|
|
778
|
+
* has no cart to drop, so it goes to the same purchase too (answerIn).
|
|
779
|
+
*/
|
|
780
|
+
let declinedNoCart = false;
|
|
692
781
|
/** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
|
|
693
782
|
let lastConversationId = null;
|
|
694
783
|
let lastCarts = [];
|
|
@@ -718,6 +807,10 @@ export function buyTool(options) {
|
|
|
718
807
|
const said = productWords(text);
|
|
719
808
|
return said !== '' && shownNames.some((name) => said.includes(name));
|
|
720
809
|
};
|
|
810
|
+
/** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
|
|
811
|
+
let lastAnswerProducts = [];
|
|
812
|
+
let lastAnswerStores = [];
|
|
813
|
+
let previousTurn = null;
|
|
721
814
|
/** 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. */
|
|
722
815
|
let shownStoreNames = [];
|
|
723
816
|
/** True for a pick of a product or a store the shopper saw, by its name. */
|
|
@@ -1143,9 +1236,7 @@ export function buyTool(options) {
|
|
|
1143
1236
|
if (!before.has(key)) {
|
|
1144
1237
|
try {
|
|
1145
1238
|
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
1146
|
-
|
|
1147
|
-
// A flight option has no page at a store: its card carries no url, and its productId picks it.
|
|
1148
|
-
await send({ kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null });
|
|
1239
|
+
await send(productLink(product));
|
|
1149
1240
|
}
|
|
1150
1241
|
catch {
|
|
1151
1242
|
continue;
|
|
@@ -1325,7 +1416,11 @@ export function buyTool(options) {
|
|
|
1325
1416
|
// With carts at more than one store waiting, a new order would drop every one of them: "cancel the
|
|
1326
1417
|
// reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
|
|
1327
1418
|
// The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
|
|
1328
|
-
|
|
1419
|
+
// After a checkout that ended with no order and no cart, a new order has no cart to drop: the shopper's yes to
|
|
1420
|
+
// trying again went out as one, and the new conversation searched again and asked for the delivery address a
|
|
1421
|
+
// second time (Commerce SDK playground, duralexusa.com, 2026-10-27). The ask stays in the purchase that holds
|
|
1422
|
+
// the product, the size and the address, until the next answer.
|
|
1423
|
+
const newOrder = input.new_order && lastCartCount < 2 && !declinedNoCart;
|
|
1329
1424
|
// A new order is never a confirm: a model that sets both drops the cart and places nothing, even
|
|
1330
1425
|
// when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
|
|
1331
1426
|
const confirm = input.confirm && !input.new_order;
|
|
@@ -1374,31 +1469,186 @@ export function buyTool(options) {
|
|
|
1374
1469
|
const callTurn = t.number;
|
|
1375
1470
|
const send = senderOf(t);
|
|
1376
1471
|
const progressOf = t.onProgress;
|
|
1377
|
-
//
|
|
1378
|
-
|
|
1472
|
+
// Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
|
|
1473
|
+
// confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
|
|
1474
|
+
// has no handler, and the client asks for the whole answer.
|
|
1475
|
+
const streams = o.stream !== false;
|
|
1379
1476
|
const early = earlyCards && !!send && !confirmSent;
|
|
1380
1477
|
running += 1;
|
|
1381
1478
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1382
1479
|
// fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
|
|
1383
1480
|
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1481
|
+
/** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
|
|
1482
|
+
let settleLeft = null;
|
|
1384
1483
|
try {
|
|
1385
1484
|
let turn = null;
|
|
1386
1485
|
let refusal;
|
|
1387
|
-
|
|
1486
|
+
/** 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. */
|
|
1487
|
+
const told = [];
|
|
1488
|
+
/**
|
|
1489
|
+
* The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
|
|
1490
|
+
* and every message after it, so the shopper reads them in the order Agentcard wrote them.
|
|
1491
|
+
*/
|
|
1492
|
+
const waiting = [];
|
|
1493
|
+
/** The products past the cards of the catalog this call heard last, for the count that follows it. */
|
|
1494
|
+
let moreFound = null;
|
|
1495
|
+
/** Whether this call's cards went out, and a count that came before them. */
|
|
1496
|
+
let cardsOut = false;
|
|
1497
|
+
let heldCount = null;
|
|
1498
|
+
/** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
|
|
1499
|
+
const cardOfId = new Map();
|
|
1500
|
+
const checking = new Map();
|
|
1501
|
+
/**
|
|
1502
|
+
* What the live search answered for each card, so a later copy of the same place, such as a card whose first send
|
|
1503
|
+
* failed and goes out again with the answer, carries that answer and never goes back to `checking`.
|
|
1504
|
+
*/
|
|
1505
|
+
const answered = new Map();
|
|
1506
|
+
const withAnswers = (products) => answered.size === 0 ? products : products.map((product) => {
|
|
1507
|
+
const known = product.availability ? answered.get(pickId(product) ?? '') : undefined;
|
|
1508
|
+
return known ? { ...product, availability: known } : product;
|
|
1509
|
+
});
|
|
1510
|
+
/**
|
|
1511
|
+
* Notes the card each product goes out as, and each place still `checking`, whichever way the app got it: a card
|
|
1512
|
+
* through `sendLink` now or on a later try, or a product past the cards that `onFound` hands over for "show more".
|
|
1513
|
+
*/
|
|
1514
|
+
const noteCards = (products, conversationId) => {
|
|
1515
|
+
for (const product of products) {
|
|
1516
|
+
const productId = pickId(product);
|
|
1517
|
+
if (!productId)
|
|
1518
|
+
continue;
|
|
1519
|
+
cardOfId.set(product.id, productId);
|
|
1520
|
+
if (product.availability?.status === 'checking' && !answered.has(productId))
|
|
1521
|
+
checking.set(productId, conversationId);
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
/** Hands the app what the live search said of the places, each by its card, and settles those cards. */
|
|
1525
|
+
settleLeft = async () => {
|
|
1526
|
+
if (!t.onAvailability || !early)
|
|
1527
|
+
return;
|
|
1528
|
+
const left = [...checking];
|
|
1529
|
+
for (const conversationId of new Set(left.map(([, c]) => c))) {
|
|
1530
|
+
await tellAvailability(conversationId, left.filter(([, c]) => c === conversationId).map(([productId]) => ({ productId, availability: { status: 'unknown' } })));
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
const tellAvailability = async (conversationId, items) => {
|
|
1534
|
+
for (const item of items) {
|
|
1535
|
+
checking.delete(item.productId);
|
|
1536
|
+
answered.set(item.productId, item.availability);
|
|
1537
|
+
}
|
|
1538
|
+
if (!t.onAvailability || items.length === 0)
|
|
1539
|
+
return;
|
|
1540
|
+
try {
|
|
1541
|
+
await t.onAvailability({ conversationId, items });
|
|
1542
|
+
}
|
|
1543
|
+
catch {
|
|
1544
|
+
// The app's display failing never fails the call.
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
/** Hands the app one count with the products past the cards of the catalog it counts. */
|
|
1548
|
+
const tellCount = async ({ conversationId, shown, found }) => {
|
|
1549
|
+
const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
|
|
1550
|
+
moreFound = null;
|
|
1551
|
+
try {
|
|
1552
|
+
await t.onFound({ conversationId, shown, found, more });
|
|
1553
|
+
}
|
|
1554
|
+
catch {
|
|
1555
|
+
// The app's display failing never fails the call.
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
// Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
|
|
1559
|
+
// answers, and each message and cart to the app as Agentcard writes it.
|
|
1388
1560
|
const handlers = streams
|
|
1389
1561
|
? {
|
|
1390
1562
|
...(progressOf ? { onProgress: (progress) => { try {
|
|
1391
1563
|
progressOf(progress);
|
|
1392
1564
|
}
|
|
1393
1565
|
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
1394
|
-
...(
|
|
1566
|
+
...(t.onStatus ? { onStatus: (status) => { try {
|
|
1567
|
+
t.onStatus(status);
|
|
1568
|
+
}
|
|
1569
|
+
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
1570
|
+
// A count counts the cards the shopper already holds, so it comes only where the cards go out early (earlyCards):
|
|
1571
|
+
// with the cards held for the answer, the answer can list fewer than the count would say.
|
|
1572
|
+
...(t.onFound && early
|
|
1573
|
+
? {
|
|
1574
|
+
onFound: async ({ conversationId, shown, found }) => {
|
|
1575
|
+
// A count that comes before any card of this call (find mode's) waits for the cards it counts.
|
|
1576
|
+
if (!cardsOut) {
|
|
1577
|
+
heldCount = { conversationId, shown, found };
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
await tellCount({ conversationId, shown, found });
|
|
1581
|
+
},
|
|
1582
|
+
}
|
|
1583
|
+
: {}),
|
|
1584
|
+
...(early || t.onProducts
|
|
1395
1585
|
? {
|
|
1396
1586
|
onCatalog: async (update) => {
|
|
1587
|
+
try {
|
|
1588
|
+
await t.onProducts?.(productsOfCatalog(update.catalog), { conversationId: update.conversationId, asOf: update.asOf, merchantName: update.catalog.merchant_name });
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
// The app's display failing never fails the call.
|
|
1592
|
+
}
|
|
1593
|
+
// The products past the cards are read after the cards went out, so the first card never waits for them.
|
|
1594
|
+
const keepMore = () => { if (t.onFound && early)
|
|
1595
|
+
moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
|
|
1596
|
+
if (!early)
|
|
1597
|
+
return;
|
|
1397
1598
|
// Products arrived before the instant reply was due: they are the reply.
|
|
1398
1599
|
ack?.cancel();
|
|
1399
1600
|
await t.ackOut;
|
|
1400
1601
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
1401
|
-
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
|
|
1602
|
+
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1603
|
+
// A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
|
|
1604
|
+
noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
|
|
1605
|
+
keepMore();
|
|
1606
|
+
cardsOut = true;
|
|
1607
|
+
// A count held for these cards goes out now, unless the catalog carries its own, which follows it.
|
|
1608
|
+
const held = heldCount;
|
|
1609
|
+
heldCount = null;
|
|
1610
|
+
if (held && update.found === null)
|
|
1611
|
+
await tellCount(held);
|
|
1612
|
+
},
|
|
1613
|
+
}
|
|
1614
|
+
: {}),
|
|
1615
|
+
...(t.onAvailability && early
|
|
1616
|
+
? {
|
|
1617
|
+
onAvailability: async ({ conversationId, items }) => {
|
|
1618
|
+
await tellAvailability(conversationId, items.map(({ id, availability }) => ({ productId: cardOfId.get(id) ?? id, availability })));
|
|
1619
|
+
},
|
|
1620
|
+
}
|
|
1621
|
+
: {}),
|
|
1622
|
+
...(t.onText
|
|
1623
|
+
? {
|
|
1624
|
+
onSegment: async ({ text, conversationId }) => {
|
|
1625
|
+
// A message that lands before the instant reply was due is the reply; one after it follows it.
|
|
1626
|
+
ack?.cancel();
|
|
1627
|
+
await t.ackOut;
|
|
1628
|
+
// 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.
|
|
1629
|
+
if (waiting.length > 0 || waitsForAnswer(text)) {
|
|
1630
|
+
waiting.push({ text, conversationId });
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
try {
|
|
1634
|
+
await t.onText(text, { conversationId });
|
|
1635
|
+
told.push(text);
|
|
1636
|
+
}
|
|
1637
|
+
catch {
|
|
1638
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
}
|
|
1642
|
+
: {}),
|
|
1643
|
+
...(t.onCart
|
|
1644
|
+
? {
|
|
1645
|
+
onCart: async ({ cart, carts, conversationId }) => {
|
|
1646
|
+
try {
|
|
1647
|
+
await t.onCart(cart, { carts, conversationId });
|
|
1648
|
+
}
|
|
1649
|
+
catch {
|
|
1650
|
+
// The app's display failing never fails the call.
|
|
1651
|
+
}
|
|
1402
1652
|
},
|
|
1403
1653
|
}
|
|
1404
1654
|
: {}),
|
|
@@ -1418,6 +1668,9 @@ export function buyTool(options) {
|
|
|
1418
1668
|
// The store was still building the last request's cart: once it is in, the request that was refused goes as it was
|
|
1419
1669
|
// sent, a step's own request too (a saved ask is no longer saved once its step sent it).
|
|
1420
1670
|
if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
|
|
1671
|
+
// Only the answer of the request sent again counts toward what the shopper read.
|
|
1672
|
+
told.length = 0;
|
|
1673
|
+
waiting.length = 0;
|
|
1421
1674
|
try {
|
|
1422
1675
|
turn = await client.buy(sent, handlers);
|
|
1423
1676
|
refusal = undefined;
|
|
@@ -1429,14 +1682,69 @@ export function buyTool(options) {
|
|
|
1429
1682
|
ack?.cancel();
|
|
1430
1683
|
const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
|
|
1431
1684
|
await t.ackOut;
|
|
1432
|
-
|
|
1433
|
-
|
|
1685
|
+
// The streamed messages that waited for the answer go out in order: each the answer kept as written, and a card ask or
|
|
1686
|
+
// a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
|
|
1687
|
+
// before any link of the answer.
|
|
1688
|
+
let replaced = false;
|
|
1689
|
+
if (waiting.length > 0 && t.onText) {
|
|
1690
|
+
const rewritten = !!turn && settled.reply !== turn.reply;
|
|
1691
|
+
const kept = new Set((rewritten ? toMessages(settled.reply) : []).map((message) => message.trim()));
|
|
1692
|
+
for (const message of waiting.splice(0)) {
|
|
1693
|
+
if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
|
|
1694
|
+
replaced = true;
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
try {
|
|
1698
|
+
await t.onText(message.text, { conversationId: message.conversationId });
|
|
1699
|
+
told.push(message.text);
|
|
1700
|
+
}
|
|
1701
|
+
catch {
|
|
1702
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
|
|
1706
|
+
if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
|
|
1707
|
+
try {
|
|
1708
|
+
await t.onText(stepAsk, { conversationId: settled.conversation_id });
|
|
1709
|
+
told.push(stepAsk);
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
// Not shown, so the model asks for the step.
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
|
|
1717
|
+
// whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
|
|
1718
|
+
const read = !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
|
|
1719
|
+
const answer = withMessages(settled, turn);
|
|
1720
|
+
const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1721
|
+
// A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
|
|
1722
|
+
if (heldCount && (delivered.products?.length ?? 0) > 0) {
|
|
1723
|
+
const held = heldCount;
|
|
1724
|
+
heldCount = null;
|
|
1725
|
+
await tellCount(held);
|
|
1726
|
+
}
|
|
1727
|
+
// A call that ends with cards still `checking`, the answer's own cards included, says `unknown` of each, so none waits
|
|
1728
|
+
// for an answer that is not coming.
|
|
1729
|
+
if (delivered.conversation_id)
|
|
1730
|
+
noteCards(delivered.products ?? [], delivered.conversation_id);
|
|
1731
|
+
await settleLeft();
|
|
1732
|
+
// The message that asks for a step link already went out, so the link follows it now instead of waiting for the model's.
|
|
1733
|
+
// The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
|
|
1734
|
+
if (read)
|
|
1735
|
+
await t.releaseNow?.();
|
|
1736
|
+
const result = {
|
|
1737
|
+
...delivered,
|
|
1738
|
+
...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
|
|
1739
|
+
...(read ? { shopper_already_read_reply: true } : {}),
|
|
1740
|
+
};
|
|
1434
1741
|
lastNextAction = result.next_action;
|
|
1435
1742
|
lastReply = result.reply;
|
|
1436
|
-
// A refusal reports no carts, so only a turn's answer says how many wait.
|
|
1743
|
+
// A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
|
|
1437
1744
|
if (turn) {
|
|
1438
1745
|
lastCartCount = result.carts.length;
|
|
1439
1746
|
lastCarts = result.carts;
|
|
1747
|
+
declinedNoCart = result.status === 'declined' && result.carts.length === 0;
|
|
1440
1748
|
}
|
|
1441
1749
|
if (result.conversation_id)
|
|
1442
1750
|
lastConversationId = result.conversation_id;
|
|
@@ -1455,6 +1763,8 @@ export function buyTool(options) {
|
|
|
1455
1763
|
const storesShown = shownStores(result.stores);
|
|
1456
1764
|
if (storesShown.length > 0)
|
|
1457
1765
|
shownStoreNames = storesShown.map((store) => store.name);
|
|
1766
|
+
lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
1767
|
+
lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
|
|
1458
1768
|
t.results?.push(result);
|
|
1459
1769
|
if (t.onResult) {
|
|
1460
1770
|
try {
|
|
@@ -1467,6 +1777,8 @@ export function buyTool(options) {
|
|
|
1467
1777
|
return result;
|
|
1468
1778
|
}
|
|
1469
1779
|
finally {
|
|
1780
|
+
// A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
|
|
1781
|
+
await settleLeft?.().catch(() => { });
|
|
1470
1782
|
ack?.cancel();
|
|
1471
1783
|
running -= 1;
|
|
1472
1784
|
lastEndedAt = Date.now();
|
|
@@ -1540,7 +1852,7 @@ export function buyTool(options) {
|
|
|
1540
1852
|
const own = options ?? {};
|
|
1541
1853
|
if (typeof own !== 'object')
|
|
1542
1854
|
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
1543
|
-
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
|
|
1855
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
1544
1856
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1545
1857
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1546
1858
|
}
|
|
@@ -1555,9 +1867,10 @@ export function buyTool(options) {
|
|
|
1555
1867
|
if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
|
|
1556
1868
|
throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
|
|
1557
1869
|
const written = own.replyAsWritten ?? writtenByDefault;
|
|
1870
|
+
const hearText = own.onText ?? o.onText;
|
|
1558
1871
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1559
1872
|
const pauseOf = paceOf(own.pace);
|
|
1560
|
-
const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
|
|
1873
|
+
const feed = itemFeed(onItem || onMessage || hearText ? pauseOf : null, typingHook(own.onTyping));
|
|
1561
1874
|
// With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
|
|
1562
1875
|
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1563
1876
|
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
@@ -1616,12 +1929,21 @@ export function buyTool(options) {
|
|
|
1616
1929
|
...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
|
|
1617
1930
|
onResult: own.onResult ?? onResult,
|
|
1618
1931
|
onProgress: own.onProgress ?? onProgress,
|
|
1932
|
+
onStatus: own.onStatus ?? o.onStatus,
|
|
1933
|
+
onFound: own.onFound ?? o.onFound,
|
|
1934
|
+
onAvailability: own.onAvailability ?? o.onAvailability,
|
|
1935
|
+
onProducts: own.onProducts ?? o.onProducts,
|
|
1936
|
+
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
1937
|
+
// an app that passes onItem too hears it once.
|
|
1938
|
+
onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
|
|
1939
|
+
onCart: own.onCart ?? o.onCart,
|
|
1619
1940
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1620
1941
|
links: [],
|
|
1621
1942
|
results: [],
|
|
1622
1943
|
};
|
|
1623
1944
|
linkNow = state.sendLink;
|
|
1624
1945
|
state.release = releaseLate;
|
|
1946
|
+
state.releaseNow = releaseForReply;
|
|
1625
1947
|
state.sendLink = (link) => {
|
|
1626
1948
|
if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
|
|
1627
1949
|
return linkNow(link);
|
|
@@ -1680,6 +2002,13 @@ export function buyTool(options) {
|
|
|
1680
2002
|
}
|
|
1681
2003
|
const links = state.links;
|
|
1682
2004
|
const results = state.results;
|
|
2005
|
+
// A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
|
|
2006
|
+
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
2007
|
+
const direct = directOn && !event && running === 0
|
|
2008
|
+
? directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores })
|
|
2009
|
+
: null;
|
|
2010
|
+
if (!event)
|
|
2011
|
+
previousTurn = state;
|
|
1683
2012
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
1684
2013
|
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
1685
2014
|
let handed = null;
|
|
@@ -1737,6 +2066,7 @@ export function buyTool(options) {
|
|
|
1737
2066
|
reaction: { get: () => state.reaction, enumerable: false },
|
|
1738
2067
|
links: { get: () => [...links], enumerable: false },
|
|
1739
2068
|
results: { get: () => [...results], enumerable: false },
|
|
2069
|
+
direct: { value: direct, enumerable: false },
|
|
1740
2070
|
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
1741
2071
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1742
2072
|
// 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.
|