@agent-cards/commerce-sdk 0.13.0 → 0.14.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 +44 -4
- package/dist/ack.d.ts +24 -0
- package/dist/ack.js +8 -8
- package/dist/buy-tool.d.ts +102 -13
- package/dist/buy-tool.js +274 -15
- package/dist/client.d.ts +104 -3
- package/dist/client.js +146 -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 +17 -0
- package/dist/model-view.js +127 -2
- 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 };
|
|
205
|
+
}
|
|
200
206
|
function productsOfCatalog(catalog) {
|
|
201
207
|
return (catalog.items ?? [])
|
|
202
208
|
.filter((item) => item.name)
|
|
@@ -299,9 +305,67 @@ function withMessages(result, turn) {
|
|
|
299
305
|
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
300
306
|
return { ...result, messages: sent.flatMap(toMessages) };
|
|
301
307
|
}
|
|
308
|
+
/** True when the app showed every message of an answer that has one, each as written and in order. */
|
|
309
|
+
function readEvery(told, messages) {
|
|
310
|
+
const written = messages.map((message) => message.trim()).filter((message) => message !== '');
|
|
311
|
+
return written.length > 0 && written.length === told.length && written.every((message, i) => message === told[i].trim());
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The steps the shopper answers in the chat, none of which has a link, and the line that asks the shopper for one when
|
|
315
|
+
* a reply asks for something else instead. Each is written for the shopper, as Agentcard's messages are: an app that
|
|
316
|
+
* shows Agentcard's messages as they stream shows it in place of the ask it replaces.
|
|
317
|
+
*/
|
|
318
|
+
const CHAT_STEP_ASKS = {
|
|
319
|
+
shipping_details: "What's the shipping address for this order?",
|
|
320
|
+
guest_details: 'What name, email and phone should it go under?',
|
|
321
|
+
verification_code: "What's the verification code the store just sent you?",
|
|
322
|
+
};
|
|
323
|
+
/** Every web address in a text, with or without its scheme. */
|
|
324
|
+
const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi;
|
|
325
|
+
/** An ask for a payment card: "add your card", "save a card", "add a payment card". "Added the gift card" is a cart line. */
|
|
326
|
+
const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:payment|credit|debit)\s+)?card\b/i;
|
|
327
|
+
/**
|
|
328
|
+
* Whether a streamed message waits for the answer before the shopper reads it: it asks for a card or points at a link,
|
|
329
|
+
* which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
|
|
330
|
+
* it, so the shopper reads them in order; a message before it goes out at once.
|
|
331
|
+
*/
|
|
332
|
+
function waitsForAnswer(message) {
|
|
333
|
+
return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The answer with its reply asking for the step Agentcard named, when that step is answered in the chat (an address, a
|
|
337
|
+
* 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
|
|
338
|
+
* not hand over. Agentcard holds the add-card link back while the address comes first, yet its reply can still ask for
|
|
339
|
+
* 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
|
|
340
|
+
* to place the order:" and no link came (2026-10-26, Commerce SDK playground, a Walmart cart with no shipping address).
|
|
341
|
+
* The paragraph gives way to a line that asks the shopper for the named step, so the reply is no longer Agentcard's as
|
|
342
|
+
* written and the model words it. A streamed message that asks for a card or points at a link waits for the answer
|
|
343
|
+
* (`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
|
|
344
|
+
* as it is, as does a cart line about a card that is a product ("added the gift card").
|
|
345
|
+
*/
|
|
346
|
+
function askingForNamedStep(result, next) {
|
|
347
|
+
const ask = next ? CHAT_STEP_ASKS[next] : undefined;
|
|
348
|
+
if (!ask || typeof result.reply !== 'string')
|
|
349
|
+
return result;
|
|
350
|
+
const known = [
|
|
351
|
+
...result.links.map((link) => link.url),
|
|
352
|
+
...result.products.map((product) => product.url),
|
|
353
|
+
...result.stores.map((store) => store.url),
|
|
354
|
+
...result.carts.flatMap((c) => c.items.map((line) => line.url)),
|
|
355
|
+
...(result.cart?.items.map((line) => line.url) ?? []),
|
|
356
|
+
].filter((url) => typeof url === 'string' && url !== '');
|
|
357
|
+
// The answer hands over no add-card link beside a step that comes first (onlyNextStepLinks), so a card ask is a dead end.
|
|
358
|
+
const held = (paragraph) => CARD_ASK.test(paragraph) ||
|
|
359
|
+
(paragraph.match(URL_IN_TEXT) ?? []).some((url) => !known.some((page) => page === url || page.startsWith(url) || url.startsWith(page)));
|
|
360
|
+
const paragraphs = result.reply.split(/\n\s*\n/);
|
|
361
|
+
if (!paragraphs.some(held))
|
|
362
|
+
return result;
|
|
363
|
+
const kept = paragraphs.filter((paragraph) => !held(paragraph) && paragraph.trim() !== '');
|
|
364
|
+
return { ...result, reply: [...kept, ask].join('\n\n') };
|
|
365
|
+
}
|
|
302
366
|
/** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
|
|
303
367
|
function resultOfTurn(turn) {
|
|
304
|
-
return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
|
|
368
|
+
return askingForNamedStep(onlyNextStepLinks(resultOfStatus(turn), turn.next_action), turn.next_action);
|
|
305
369
|
}
|
|
306
370
|
/** A purchase answer, read for its status, with every link the answer carries. */
|
|
307
371
|
function resultOfStatus(turn) {
|
|
@@ -600,13 +664,23 @@ export function buyTool(options) {
|
|
|
600
664
|
const sendLink = o.sendLink;
|
|
601
665
|
if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
|
|
602
666
|
throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
|
|
667
|
+
if (o.onProducts !== undefined && typeof o.onProducts !== 'function')
|
|
668
|
+
throw new TypeError('Pass onProducts as a function that takes the products a search found.');
|
|
669
|
+
if (o.onText !== undefined && typeof o.onText !== 'function')
|
|
670
|
+
throw new TypeError('Pass onText as a function that takes each message Agentcard writes.');
|
|
671
|
+
if (o.onCart !== undefined && typeof o.onCart !== 'function')
|
|
672
|
+
throw new TypeError('Pass onCart as a function that takes the carts each time a call builds or changes one.');
|
|
603
673
|
if (o.stream !== undefined && o.stream !== false)
|
|
604
674
|
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
605
675
|
const onProgress = o.onProgress;
|
|
676
|
+
for (const key of ['onStatus', 'onFound']) {
|
|
677
|
+
if (o[key] !== undefined && typeof o[key] !== 'function')
|
|
678
|
+
throw new TypeError(`Pass ${key} as a function, or leave it out.`);
|
|
679
|
+
}
|
|
606
680
|
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
607
681
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
608
|
-
// A
|
|
609
|
-
const earlyCards = o.earlyCards
|
|
682
|
+
// A search's cards go out the moment it answers, unless the app waits for the answer that names them.
|
|
683
|
+
const earlyCards = o.earlyCards !== false;
|
|
610
684
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
611
685
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
612
686
|
}
|
|
@@ -614,6 +688,9 @@ export function buyTool(options) {
|
|
|
614
688
|
if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
|
|
615
689
|
throw new TypeError('Pass replyAsWritten as true or false.');
|
|
616
690
|
const writtenByDefault = o.replyAsWritten === true;
|
|
691
|
+
if (o.direct !== undefined && typeof o.direct !== 'boolean')
|
|
692
|
+
throw new TypeError('Pass direct as true or false.');
|
|
693
|
+
const directOn = o.direct === true;
|
|
617
694
|
const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
618
695
|
if (typeof wait !== 'function')
|
|
619
696
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
@@ -666,6 +743,11 @@ export function buyTool(options) {
|
|
|
666
743
|
tell: o.onWorking,
|
|
667
744
|
onResult,
|
|
668
745
|
onProgress,
|
|
746
|
+
onStatus: o.onStatus,
|
|
747
|
+
onFound: o.onFound,
|
|
748
|
+
onProducts: o.onProducts,
|
|
749
|
+
onText: o.onText,
|
|
750
|
+
onCart: o.onCart,
|
|
669
751
|
links: null,
|
|
670
752
|
results: null,
|
|
671
753
|
pages: null,
|
|
@@ -689,6 +771,11 @@ export function buyTool(options) {
|
|
|
689
771
|
let lastReply = null;
|
|
690
772
|
/** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
|
|
691
773
|
let lastCartCount = 0;
|
|
774
|
+
/**
|
|
775
|
+
* True when the last answer was a checkout that ended with no order and no cart waiting (`declined`): a new order then
|
|
776
|
+
* has no cart to drop, so it goes to the same purchase too (answerIn).
|
|
777
|
+
*/
|
|
778
|
+
let declinedNoCart = false;
|
|
692
779
|
/** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
|
|
693
780
|
let lastConversationId = null;
|
|
694
781
|
let lastCarts = [];
|
|
@@ -718,6 +805,10 @@ export function buyTool(options) {
|
|
|
718
805
|
const said = productWords(text);
|
|
719
806
|
return said !== '' && shownNames.some((name) => said.includes(name));
|
|
720
807
|
};
|
|
808
|
+
/** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
|
|
809
|
+
let lastAnswerProducts = [];
|
|
810
|
+
let lastAnswerStores = [];
|
|
811
|
+
let previousTurn = null;
|
|
721
812
|
/** 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
813
|
let shownStoreNames = [];
|
|
723
814
|
/** True for a pick of a product or a store the shopper saw, by its name. */
|
|
@@ -1143,9 +1234,7 @@ export function buyTool(options) {
|
|
|
1143
1234
|
if (!before.has(key)) {
|
|
1144
1235
|
try {
|
|
1145
1236
|
// 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 });
|
|
1237
|
+
await send(productLink(product));
|
|
1149
1238
|
}
|
|
1150
1239
|
catch {
|
|
1151
1240
|
continue;
|
|
@@ -1325,7 +1414,11 @@ export function buyTool(options) {
|
|
|
1325
1414
|
// With carts at more than one store waiting, a new order would drop every one of them: "cancel the
|
|
1326
1415
|
// reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
|
|
1327
1416
|
// The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
|
|
1328
|
-
|
|
1417
|
+
// After a checkout that ended with no order and no cart, a new order has no cart to drop: the shopper's yes to
|
|
1418
|
+
// trying again went out as one, and the new conversation searched again and asked for the delivery address a
|
|
1419
|
+
// second time (Commerce SDK playground, duralexusa.com, 2026-10-27). The ask stays in the purchase that holds
|
|
1420
|
+
// the product, the size and the address, until the next answer.
|
|
1421
|
+
const newOrder = input.new_order && lastCartCount < 2 && !declinedNoCart;
|
|
1329
1422
|
// A new order is never a confirm: a model that sets both drops the cart and places nothing, even
|
|
1330
1423
|
// when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
|
|
1331
1424
|
const confirm = input.confirm && !input.new_order;
|
|
@@ -1374,8 +1467,10 @@ export function buyTool(options) {
|
|
|
1374
1467
|
const callTurn = t.number;
|
|
1375
1468
|
const send = senderOf(t);
|
|
1376
1469
|
const progressOf = t.onProgress;
|
|
1377
|
-
//
|
|
1378
|
-
|
|
1470
|
+
// Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
|
|
1471
|
+
// confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
|
|
1472
|
+
// has no handler, and the client asks for the whole answer.
|
|
1473
|
+
const streams = o.stream !== false;
|
|
1379
1474
|
const early = earlyCards && !!send && !confirmSent;
|
|
1380
1475
|
running += 1;
|
|
1381
1476
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
@@ -1384,21 +1479,114 @@ export function buyTool(options) {
|
|
|
1384
1479
|
try {
|
|
1385
1480
|
let turn = null;
|
|
1386
1481
|
let refusal;
|
|
1387
|
-
|
|
1482
|
+
/** 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. */
|
|
1483
|
+
const told = [];
|
|
1484
|
+
/**
|
|
1485
|
+
* The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
|
|
1486
|
+
* and every message after it, so the shopper reads them in the order Agentcard wrote them.
|
|
1487
|
+
*/
|
|
1488
|
+
const waiting = [];
|
|
1489
|
+
/** The products past the cards of the catalog this call heard last, for the count that follows it. */
|
|
1490
|
+
let moreFound = null;
|
|
1491
|
+
/** Whether this call's cards went out, and a count that came before them. */
|
|
1492
|
+
let cardsOut = false;
|
|
1493
|
+
let heldCount = null;
|
|
1494
|
+
/** Hands the app one count with the products past the cards of the catalog it counts. */
|
|
1495
|
+
const tellCount = async ({ conversationId, shown, found }) => {
|
|
1496
|
+
const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
|
|
1497
|
+
moreFound = null;
|
|
1498
|
+
try {
|
|
1499
|
+
await t.onFound({ conversationId, shown, found, more });
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
// The app's display failing never fails the call.
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
// Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
|
|
1506
|
+
// answers, and each message and cart to the app as Agentcard writes it.
|
|
1388
1507
|
const handlers = streams
|
|
1389
1508
|
? {
|
|
1390
1509
|
...(progressOf ? { onProgress: (progress) => { try {
|
|
1391
1510
|
progressOf(progress);
|
|
1392
1511
|
}
|
|
1393
1512
|
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
1394
|
-
...(
|
|
1513
|
+
...(t.onStatus ? { onStatus: (status) => { try {
|
|
1514
|
+
t.onStatus(status);
|
|
1515
|
+
}
|
|
1516
|
+
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
1517
|
+
// A count counts the cards the shopper already holds, so it comes only where the cards go out early (earlyCards):
|
|
1518
|
+
// with the cards held for the answer, the answer can list fewer than the count would say.
|
|
1519
|
+
...(t.onFound && early
|
|
1520
|
+
? {
|
|
1521
|
+
onFound: async ({ conversationId, shown, found }) => {
|
|
1522
|
+
// A count that comes before any card of this call (find mode's) waits for the cards it counts.
|
|
1523
|
+
if (!cardsOut) {
|
|
1524
|
+
heldCount = { conversationId, shown, found };
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
await tellCount({ conversationId, shown, found });
|
|
1528
|
+
},
|
|
1529
|
+
}
|
|
1530
|
+
: {}),
|
|
1531
|
+
...(early || t.onProducts
|
|
1395
1532
|
? {
|
|
1396
1533
|
onCatalog: async (update) => {
|
|
1534
|
+
try {
|
|
1535
|
+
await t.onProducts?.(productsOfCatalog(update.catalog), { conversationId: update.conversationId, asOf: update.asOf, merchantName: update.catalog.merchant_name });
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
// The app's display failing never fails the call.
|
|
1539
|
+
}
|
|
1540
|
+
// The products past the cards are read after the cards went out, so the first card never waits for them.
|
|
1541
|
+
const keepMore = () => { if (t.onFound && early)
|
|
1542
|
+
moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
|
|
1543
|
+
if (!early)
|
|
1544
|
+
return;
|
|
1397
1545
|
// Products arrived before the instant reply was due: they are the reply.
|
|
1398
1546
|
ack?.cancel();
|
|
1399
1547
|
await t.ackOut;
|
|
1400
1548
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
1401
1549
|
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
|
|
1550
|
+
keepMore();
|
|
1551
|
+
cardsOut = true;
|
|
1552
|
+
// A count held for these cards goes out now, unless the catalog carries its own, which follows it.
|
|
1553
|
+
const held = heldCount;
|
|
1554
|
+
heldCount = null;
|
|
1555
|
+
if (held && update.found === null)
|
|
1556
|
+
await tellCount(held);
|
|
1557
|
+
},
|
|
1558
|
+
}
|
|
1559
|
+
: {}),
|
|
1560
|
+
...(t.onText
|
|
1561
|
+
? {
|
|
1562
|
+
onSegment: async ({ text, conversationId }) => {
|
|
1563
|
+
// A message that lands before the instant reply was due is the reply; one after it follows it.
|
|
1564
|
+
ack?.cancel();
|
|
1565
|
+
await t.ackOut;
|
|
1566
|
+
// 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.
|
|
1567
|
+
if (waiting.length > 0 || waitsForAnswer(text)) {
|
|
1568
|
+
waiting.push({ text, conversationId });
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
try {
|
|
1572
|
+
await t.onText(text, { conversationId });
|
|
1573
|
+
told.push(text);
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
}
|
|
1580
|
+
: {}),
|
|
1581
|
+
...(t.onCart
|
|
1582
|
+
? {
|
|
1583
|
+
onCart: async ({ cart, carts, conversationId }) => {
|
|
1584
|
+
try {
|
|
1585
|
+
await t.onCart(cart, { carts, conversationId });
|
|
1586
|
+
}
|
|
1587
|
+
catch {
|
|
1588
|
+
// The app's display failing never fails the call.
|
|
1589
|
+
}
|
|
1402
1590
|
},
|
|
1403
1591
|
}
|
|
1404
1592
|
: {}),
|
|
@@ -1418,6 +1606,9 @@ export function buyTool(options) {
|
|
|
1418
1606
|
// The store was still building the last request's cart: once it is in, the request that was refused goes as it was
|
|
1419
1607
|
// sent, a step's own request too (a saved ask is no longer saved once its step sent it).
|
|
1420
1608
|
if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
|
|
1609
|
+
// Only the answer of the request sent again counts toward what the shopper read.
|
|
1610
|
+
told.length = 0;
|
|
1611
|
+
waiting.length = 0;
|
|
1421
1612
|
try {
|
|
1422
1613
|
turn = await client.buy(sent, handlers);
|
|
1423
1614
|
refusal = undefined;
|
|
@@ -1429,14 +1620,63 @@ export function buyTool(options) {
|
|
|
1429
1620
|
ack?.cancel();
|
|
1430
1621
|
const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
|
|
1431
1622
|
await t.ackOut;
|
|
1623
|
+
// The streamed messages that waited for the answer go out in order: each the answer kept as written, and a card ask or
|
|
1624
|
+
// a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
|
|
1625
|
+
// before any link of the answer.
|
|
1626
|
+
let replaced = false;
|
|
1627
|
+
if (waiting.length > 0 && t.onText) {
|
|
1628
|
+
const rewritten = !!turn && settled.reply !== turn.reply;
|
|
1629
|
+
const kept = new Set((rewritten ? toMessages(settled.reply) : []).map((message) => message.trim()));
|
|
1630
|
+
for (const message of waiting.splice(0)) {
|
|
1631
|
+
if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
|
|
1632
|
+
replaced = true;
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
try {
|
|
1636
|
+
await t.onText(message.text, { conversationId: message.conversationId });
|
|
1637
|
+
told.push(message.text);
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
|
|
1644
|
+
if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
|
|
1645
|
+
try {
|
|
1646
|
+
await t.onText(stepAsk, { conversationId: settled.conversation_id });
|
|
1647
|
+
told.push(stepAsk);
|
|
1648
|
+
}
|
|
1649
|
+
catch {
|
|
1650
|
+
// Not shown, so the model asks for the step.
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
// Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
|
|
1655
|
+
// whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
|
|
1656
|
+
const read = !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
|
|
1432
1657
|
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1433
|
-
|
|
1658
|
+
// A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
|
|
1659
|
+
if (heldCount && (delivered.products?.length ?? 0) > 0) {
|
|
1660
|
+
const held = heldCount;
|
|
1661
|
+
heldCount = null;
|
|
1662
|
+
await tellCount(held);
|
|
1663
|
+
}
|
|
1664
|
+
// The message that asks for a step link already went out, so the link follows it now instead of waiting for the model's.
|
|
1665
|
+
// The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
|
|
1666
|
+
if (read)
|
|
1667
|
+
await t.releaseNow?.();
|
|
1668
|
+
const result = {
|
|
1669
|
+
...delivered,
|
|
1670
|
+
...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
|
|
1671
|
+
...(read ? { shopper_already_read_reply: true } : {}),
|
|
1672
|
+
};
|
|
1434
1673
|
lastNextAction = result.next_action;
|
|
1435
1674
|
lastReply = result.reply;
|
|
1436
|
-
// A refusal reports no carts, so only a turn's answer says how many wait.
|
|
1675
|
+
// A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
|
|
1437
1676
|
if (turn) {
|
|
1438
1677
|
lastCartCount = result.carts.length;
|
|
1439
1678
|
lastCarts = result.carts;
|
|
1679
|
+
declinedNoCart = result.status === 'declined' && result.carts.length === 0;
|
|
1440
1680
|
}
|
|
1441
1681
|
if (result.conversation_id)
|
|
1442
1682
|
lastConversationId = result.conversation_id;
|
|
@@ -1455,6 +1695,8 @@ export function buyTool(options) {
|
|
|
1455
1695
|
const storesShown = shownStores(result.stores);
|
|
1456
1696
|
if (storesShown.length > 0)
|
|
1457
1697
|
shownStoreNames = storesShown.map((store) => store.name);
|
|
1698
|
+
lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
1699
|
+
lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
|
|
1458
1700
|
t.results?.push(result);
|
|
1459
1701
|
if (t.onResult) {
|
|
1460
1702
|
try {
|
|
@@ -1540,7 +1782,7 @@ export function buyTool(options) {
|
|
|
1540
1782
|
const own = options ?? {};
|
|
1541
1783
|
if (typeof own !== 'object')
|
|
1542
1784
|
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']) {
|
|
1785
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
1544
1786
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1545
1787
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1546
1788
|
}
|
|
@@ -1555,9 +1797,10 @@ export function buyTool(options) {
|
|
|
1555
1797
|
if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
|
|
1556
1798
|
throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
|
|
1557
1799
|
const written = own.replyAsWritten ?? writtenByDefault;
|
|
1800
|
+
const hearText = own.onText ?? o.onText;
|
|
1558
1801
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1559
1802
|
const pauseOf = paceOf(own.pace);
|
|
1560
|
-
const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
|
|
1803
|
+
const feed = itemFeed(onItem || onMessage || hearText ? pauseOf : null, typingHook(own.onTyping));
|
|
1561
1804
|
// 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
1805
|
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1563
1806
|
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
@@ -1616,12 +1859,20 @@ export function buyTool(options) {
|
|
|
1616
1859
|
...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
|
|
1617
1860
|
onResult: own.onResult ?? onResult,
|
|
1618
1861
|
onProgress: own.onProgress ?? onProgress,
|
|
1862
|
+
onStatus: own.onStatus ?? o.onStatus,
|
|
1863
|
+
onFound: own.onFound ?? o.onFound,
|
|
1864
|
+
onProducts: own.onProducts ?? o.onProducts,
|
|
1865
|
+
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
1866
|
+
// an app that passes onItem too hears it once.
|
|
1867
|
+
onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
|
|
1868
|
+
onCart: own.onCart ?? o.onCart,
|
|
1619
1869
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1620
1870
|
links: [],
|
|
1621
1871
|
results: [],
|
|
1622
1872
|
};
|
|
1623
1873
|
linkNow = state.sendLink;
|
|
1624
1874
|
state.release = releaseLate;
|
|
1875
|
+
state.releaseNow = releaseForReply;
|
|
1625
1876
|
state.sendLink = (link) => {
|
|
1626
1877
|
if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
|
|
1627
1878
|
return linkNow(link);
|
|
@@ -1680,6 +1931,13 @@ export function buyTool(options) {
|
|
|
1680
1931
|
}
|
|
1681
1932
|
const links = state.links;
|
|
1682
1933
|
const results = state.results;
|
|
1934
|
+
// A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
|
|
1935
|
+
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
1936
|
+
const direct = directOn && !event && running === 0
|
|
1937
|
+
? directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores })
|
|
1938
|
+
: null;
|
|
1939
|
+
if (!event)
|
|
1940
|
+
previousTurn = state;
|
|
1683
1941
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
1684
1942
|
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
1685
1943
|
let handed = null;
|
|
@@ -1737,6 +1995,7 @@ export function buyTool(options) {
|
|
|
1737
1995
|
reaction: { get: () => state.reaction, enumerable: false },
|
|
1738
1996
|
links: { get: () => [...links], enumerable: false },
|
|
1739
1997
|
results: { get: () => [...results], enumerable: false },
|
|
1998
|
+
direct: { value: direct, enumerable: false },
|
|
1740
1999
|
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
1741
2000
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1742
2001
|
// 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.
|
package/dist/client.d.ts
CHANGED
|
@@ -44,20 +44,118 @@ export interface BuyCatalogUpdate {
|
|
|
44
44
|
catalog: BuyCatalog;
|
|
45
45
|
/** When the search ran, as the answer's `catalog.as_of` names it: the same search in the answer has the same value. */
|
|
46
46
|
asOf: string | null;
|
|
47
|
+
/** How many of the products found are in `catalog.items`, the cards to show. Null when Agentcard sent no count. */
|
|
48
|
+
shown: number | null;
|
|
49
|
+
/** How many distinct products the turn's searches found so far. Null when Agentcard sent no count. */
|
|
50
|
+
found: number | null;
|
|
51
|
+
/** The products found past the cards, in the order they were found, to show on "show more" with no new search. */
|
|
52
|
+
more: BuyCatalog['items'];
|
|
47
53
|
}
|
|
48
|
-
/**
|
|
54
|
+
/** How many products a running turn found so far, and how many of them it shows: "8 of 23 shown". */
|
|
55
|
+
export interface BuyFoundUpdate {
|
|
56
|
+
conversationId: string;
|
|
57
|
+
shown: number;
|
|
58
|
+
found: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* What a running turn is doing, the moment a step starts: `text` is a short line to show in place of loading dots, such as
|
|
62
|
+
* `searching Target and Walmart` or `adding it to your cart`, and `step` names the step for an app that writes its own,
|
|
63
|
+
* such as `searching`, `adding_to_cart` or `store_checkout`. No status comes for a turn that only writes its reply.
|
|
64
|
+
*/
|
|
65
|
+
export interface BuyTurnStatus {
|
|
66
|
+
conversationId: string | null;
|
|
67
|
+
step: string;
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
/** One message Agentcard finished writing while the turn runs, links included, as the answer's `messages` then holds it. */
|
|
71
|
+
export interface BuySegmentUpdate {
|
|
72
|
+
conversationId: string | null;
|
|
73
|
+
text: string;
|
|
74
|
+
}
|
|
75
|
+
/** The carts on the table the moment a running turn builds or changes one, before the message that announces it. */
|
|
76
|
+
export interface BuyCartUpdate {
|
|
77
|
+
conversationId: string;
|
|
78
|
+
/** The cart a confirm would place, as the answer's `cart` names it, or null when none would be. */
|
|
79
|
+
cart: BuyCart | null;
|
|
80
|
+
/** Every cart on the table, oldest first, as the answer's `carts`. */
|
|
81
|
+
carts: BuyCart[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A piece of a message while Agentcard writes it. A piece can hold words Agentcard drops before the message is done, such
|
|
85
|
+
* as a line that only says it is looking, so show the pieces as a draft and replace them with each `onSegment` message.
|
|
86
|
+
*/
|
|
87
|
+
export interface BuyDeltaUpdate {
|
|
88
|
+
conversationId: string | null;
|
|
89
|
+
text: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Called while a purchase turn runs. Passing any one asks Agentcard to stream the turn. `onCatalog`, `onSegment`,
|
|
93
|
+
* `onCart` and `onDelta` hear their events one at a time, in the order Agentcard sent them, each once the one before it
|
|
94
|
+
* settled, so a piece of the next message never reaches the app before the message ahead of it. The client waits for
|
|
95
|
+
* the last of them before it hands back the turn's answer.
|
|
96
|
+
*/
|
|
49
97
|
export interface BuyStreamHandlers {
|
|
50
98
|
onProgress?: (progress: BuyProgress) => void;
|
|
51
|
-
/**
|
|
99
|
+
/** Each step the turn starts, as a line to show while the shopper waits. */
|
|
100
|
+
onStatus?: (status: BuyTurnStatus) => void;
|
|
101
|
+
/** How many products the turn found, each time the count grows. */
|
|
102
|
+
onFound?: (update: BuyFoundUpdate) => void | Promise<void>;
|
|
52
103
|
onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
|
|
104
|
+
/** Each message Agentcard writes, the moment it is written. */
|
|
105
|
+
onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
|
|
106
|
+
/** The carts, each time the turn builds or changes one. */
|
|
107
|
+
onCart?: (update: BuyCartUpdate) => void | Promise<void>;
|
|
108
|
+
/** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
|
|
109
|
+
onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
|
|
53
110
|
}
|
|
54
111
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
55
112
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
56
113
|
export declare const BUY_TURN_TIMEOUT_MS = 130000;
|
|
114
|
+
/** The W3C trace context a `POST /buy` carries, so the backend's spans join the caller's trace. */
|
|
115
|
+
export interface TraceContext {
|
|
116
|
+
traceparent: string;
|
|
117
|
+
tracestate?: string;
|
|
118
|
+
}
|
|
119
|
+
/** The part of `@opentelemetry/api` the client calls: pass the module itself as `otel`. */
|
|
120
|
+
export interface OtelApi {
|
|
121
|
+
trace: {
|
|
122
|
+
getTracer(name: string): OtelTracer;
|
|
123
|
+
};
|
|
124
|
+
context: {
|
|
125
|
+
active(): unknown;
|
|
126
|
+
};
|
|
127
|
+
propagation: {
|
|
128
|
+
inject(context: unknown, carrier: Record<string, string>): void;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
interface OtelTracer {
|
|
132
|
+
startActiveSpan<T>(name: string, options: {
|
|
133
|
+
kind?: number;
|
|
134
|
+
attributes?: Record<string, string | boolean>;
|
|
135
|
+
}, fn: (span: OtelSpan) => T): T;
|
|
136
|
+
}
|
|
137
|
+
interface OtelSpan {
|
|
138
|
+
setAttribute(key: string, value: string | number | boolean): unknown;
|
|
139
|
+
setStatus(status: {
|
|
140
|
+
code: number;
|
|
141
|
+
message?: string;
|
|
142
|
+
}): unknown;
|
|
143
|
+
end(): void;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* How the client finds the trace a `POST /buy` belongs to. `otel` is `@opentelemetry/api`: by default the client
|
|
147
|
+
* loads it when the app installed it, and `false` turns that off. `traceContext` names the trace by hand, for an app
|
|
148
|
+
* without OpenTelemetry: return its `traceparent`, or nothing to send none. The active OpenTelemetry context wins.
|
|
149
|
+
*/
|
|
150
|
+
export interface AgentcardTraceOptions {
|
|
151
|
+
otel?: OtelApi | false;
|
|
152
|
+
traceContext?: () => TraceContext | string | null | undefined;
|
|
153
|
+
}
|
|
57
154
|
/** A company's client credentials, or one shopper's own access token. */
|
|
58
155
|
export type AgentcardClientOptions = {
|
|
59
156
|
baseUrl?: string;
|
|
60
157
|
fetch?: FetchLike;
|
|
158
|
+
trace?: AgentcardTraceOptions;
|
|
61
159
|
} & ({
|
|
62
160
|
clientId: string;
|
|
63
161
|
clientSecret: string;
|
|
@@ -155,7 +253,8 @@ export interface BuyCart {
|
|
|
155
253
|
items: BuyCartLine[];
|
|
156
254
|
/** The items alone, or null from an older API. */
|
|
157
255
|
subtotalCents: number | null;
|
|
158
|
-
serviceFeesCents: number;
|
|
256
|
+
serviceFeesCents: number | null;
|
|
257
|
+
serviceFeesStatus?: 'known' | 'pending' | 'unknown';
|
|
159
258
|
/** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
|
|
160
259
|
merchantFeeLines: BuyCartFeeLine[] | null;
|
|
161
260
|
tipCents: number;
|
|
@@ -393,6 +492,7 @@ export declare class AgentcardClient {
|
|
|
393
492
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
394
493
|
private readonly inbox;
|
|
395
494
|
private readonly deliveredIds;
|
|
495
|
+
private readonly trace;
|
|
396
496
|
constructor(options: AgentcardClientOptions);
|
|
397
497
|
/** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
|
|
398
498
|
get company(): boolean;
|
|
@@ -459,3 +559,4 @@ export declare class AgentcardClient {
|
|
|
459
559
|
private renewAfter;
|
|
460
560
|
private exchange;
|
|
461
561
|
}
|
|
562
|
+
export {};
|