@agent-cards/commerce-sdk 0.14.0 → 0.16.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 +53 -5
- package/dist/ack.d.ts +26 -16
- package/dist/ack.js +64 -2
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +2 -0
- package/dist/buy-tool.d.ts +73 -8
- package/dist/buy-tool.js +304 -32
- package/dist/client.d.ts +58 -0
- package/dist/client.js +43 -2
- package/dist/definition.js +2 -0
- package/dist/direct.d.ts +5 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -1
- package/dist/messages.js +4 -4
- package/dist/model-view.d.ts +59 -7
- package/dist/model-view.js +231 -31
- package/dist/order.d.ts +119 -0
- package/dist/order.js +155 -0
- package/dist/sentences.d.ts +3 -0
- package/dist/sentences.js +29 -0
- package/dist/shopper-message.d.ts +10 -0
- package/dist/shopper-message.js +51 -11
- package/dist/texting.d.ts +81 -0
- package/dist/texting.js +347 -0
- package/package.json +1 -1
package/dist/buy-tool.js
CHANGED
|
@@ -33,12 +33,13 @@
|
|
|
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';
|
|
37
|
-
import { forModel, forModelError, pickId, productPrice, productCardKey,
|
|
36
|
+
import { directCall, isProductAsk } from './direct.js';
|
|
37
|
+
import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
38
|
+
import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
|
|
38
39
|
import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
|
|
39
40
|
import { isStale } from './shopper-message.js';
|
|
40
41
|
import { actionLinkId, isActionLink } from './action-links.js';
|
|
41
|
-
import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
|
|
42
|
+
import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
|
|
42
43
|
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
43
44
|
export function productAction(merchant) {
|
|
44
45
|
if (merchant === 'reservations')
|
|
@@ -145,6 +146,70 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
|
|
|
145
146
|
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.';
|
|
146
147
|
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.";
|
|
147
148
|
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.";
|
|
149
|
+
/** One cart line as the order holds it: the product, its variant, its choices and its quantity. Prices are left out. */
|
|
150
|
+
const lineKey = (line) => JSON.stringify([line.product_id ?? String(line.name ?? '').trim().toLowerCase(), line.variant ?? null, [...(line.options ?? [])].sort(), line.qty]);
|
|
151
|
+
/**
|
|
152
|
+
* How two matched lines' prices stand: false when a price they both name moved, true when one they both name agrees, and
|
|
153
|
+
* null when they name none in common, so the lines say nothing about what they cost.
|
|
154
|
+
*/
|
|
155
|
+
function linePriceStands(a, b) {
|
|
156
|
+
const named = (x, y) => typeof x === 'number' && typeof y === 'number' ? x === y : null;
|
|
157
|
+
const read = [named(a.priceCents, b.priceCents), named(a.lineTotalCents, b.lineTotalCents)];
|
|
158
|
+
if (read.includes(false))
|
|
159
|
+
return false;
|
|
160
|
+
return read.includes(true) ? true : null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* True when two carts are the same order: the same store, the same items in the same quantities with the same choices,
|
|
164
|
+
* and the same price for those items. Only the total may differ, as when the store prices its fees, tax and delivery in
|
|
165
|
+
* once the shopper's account holds the cart (Mendocino Farms on DoorDash, 2026-10-27: about $15.90, then $17.89).
|
|
166
|
+
*
|
|
167
|
+
* What the items cost has to be shown to be the same: every matched line's prices agree, or both carts name a subtotal
|
|
168
|
+
* and it is equal. A line whose price moved, two lines whose prices swapped under one subtotal, and a cart nothing
|
|
169
|
+
* prices are each a different order, so the shopper is asked again (review of #11087).
|
|
170
|
+
*/
|
|
171
|
+
function sameOrder(a, b) {
|
|
172
|
+
if (a.merchant !== b.merchant || (a.store?.id ?? null) !== (b.store?.id ?? null))
|
|
173
|
+
return false;
|
|
174
|
+
if ((a.currency ?? null) !== (b.currency ?? null))
|
|
175
|
+
return false;
|
|
176
|
+
const subtotals = typeof a.subtotalCents === 'number' && typeof b.subtotalCents === 'number' ? a.subtotalCents === b.subtotalCents : null;
|
|
177
|
+
if (subtotals === false)
|
|
178
|
+
return false;
|
|
179
|
+
const lines = (cart) => [...(cart.items ?? [])].sort((p, q) => (lineKey(p) < lineKey(q) ? -1 : lineKey(p) > lineKey(q) ? 1 : 0));
|
|
180
|
+
const [x, y] = [lines(a), lines(b)];
|
|
181
|
+
if (x.length === 0 || x.length !== y.length || !x.every((line, i) => lineKey(line) === lineKey(y[i])))
|
|
182
|
+
return false;
|
|
183
|
+
const prices = x.map((line, i) => linePriceStands(line, y[i]));
|
|
184
|
+
if (prices.includes(false))
|
|
185
|
+
return false;
|
|
186
|
+
return subtotals === true || prices.every((stands) => stands === true);
|
|
187
|
+
}
|
|
188
|
+
/** True when `now` holds an item `before` did not: the call put something new in the cart. */
|
|
189
|
+
function addsItem(now, before) {
|
|
190
|
+
if (!now || (now.items ?? []).length === 0)
|
|
191
|
+
return false;
|
|
192
|
+
const had = new Set((before && before.merchant === now.merchant ? before.items ?? [] : []).map((line) => lineKey({ ...line, qty: 0 })));
|
|
193
|
+
return (now.items ?? []).some((line) => !had.has(lineKey({ ...line, qty: 0 })));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The yes left standing when a confirm stopped for a step outside the chat, or null. The cart the confirm stopped on must
|
|
197
|
+
* be the same order as the one the yes covers; the total the shopper said yes to stays the one they saw.
|
|
198
|
+
*/
|
|
199
|
+
function yesAfter(result, yes) {
|
|
200
|
+
const stopped = result.cart ?? (result.carts.length === 1 ? result.carts[0] : null);
|
|
201
|
+
if ('carried' in yes) {
|
|
202
|
+
if (stopped && !sameOrder(stopped, yes.carried.cart))
|
|
203
|
+
return null;
|
|
204
|
+
return { ...yes.carried, cart: stopped ?? yes.carried.cart, conversationId: result.conversation_id ?? yes.carried.conversationId };
|
|
205
|
+
}
|
|
206
|
+
const seen = yes.shown ?? stopped;
|
|
207
|
+
if (!seen || (yes.shown && stopped && !sameOrder(yes.shown, stopped)))
|
|
208
|
+
return null;
|
|
209
|
+
return { conversationId: result.conversation_id, cart: stopped ?? seen, totalCents: seen.totalCents };
|
|
210
|
+
}
|
|
211
|
+
/** The words of the call a `nextStep` turn's `direct` makes. They never reach Agentcard: the turn's calls send no ask. */
|
|
212
|
+
const NEXT_STEP_ASK = 'take the next step';
|
|
148
213
|
/** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
|
|
149
214
|
const ASK_FOR_YES = "The cart is ready, and the shopper has not said yes to it in the chat, so nothing was confirmed. Say the cart's total in one short line and ask for their yes.";
|
|
150
215
|
const STEP_AFTER_DECLINE = 'The shopper declined the approval, so nothing was charged and no order was placed. The cart is still here. Say so in one short line and offer the way forward: their yes sends a new approval link, or they can change the order.';
|
|
@@ -201,7 +266,7 @@ export function productsOf(turn) {
|
|
|
201
266
|
/** 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
267
|
function productLink(product) {
|
|
203
268
|
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 };
|
|
269
|
+
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
270
|
}
|
|
206
271
|
function productsOfCatalog(catalog) {
|
|
207
272
|
return (catalog.items ?? [])
|
|
@@ -220,6 +285,7 @@ function productsOfCatalog(catalog) {
|
|
|
220
285
|
url: item.url ?? null,
|
|
221
286
|
action: productAction(catalog.merchant),
|
|
222
287
|
linkSent: false,
|
|
288
|
+
...(item.availability ? { availability: item.availability } : {}),
|
|
223
289
|
}));
|
|
224
290
|
}
|
|
225
291
|
/** The stores an answer presents, as the tool reads them. */
|
|
@@ -263,6 +329,7 @@ function answerOf(turn) {
|
|
|
263
329
|
store_pick: turn.store_pick,
|
|
264
330
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
265
331
|
cart_building: turn.cart_building,
|
|
332
|
+
choice: turn.choice ?? null,
|
|
266
333
|
envelope: turn.raw,
|
|
267
334
|
};
|
|
268
335
|
}
|
|
@@ -673,7 +740,7 @@ export function buyTool(options) {
|
|
|
673
740
|
if (o.stream !== undefined && o.stream !== false)
|
|
674
741
|
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
675
742
|
const onProgress = o.onProgress;
|
|
676
|
-
for (const key of ['onStatus', 'onFound']) {
|
|
743
|
+
for (const key of ['onStatus', 'onFound', 'onAvailability']) {
|
|
677
744
|
if (o[key] !== undefined && typeof o[key] !== 'function')
|
|
678
745
|
throw new TypeError(`Pass ${key} as a function, or leave it out.`);
|
|
679
746
|
}
|
|
@@ -688,6 +755,9 @@ export function buyTool(options) {
|
|
|
688
755
|
if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
|
|
689
756
|
throw new TypeError('Pass replyAsWritten as true or false.');
|
|
690
757
|
const writtenByDefault = o.replyAsWritten === true;
|
|
758
|
+
if (o.textingStyle !== undefined && typeof o.textingStyle !== 'boolean')
|
|
759
|
+
throw new TypeError('Pass textingStyle as true or false.');
|
|
760
|
+
const textingByDefault = o.textingStyle === true;
|
|
691
761
|
if (o.direct !== undefined && typeof o.direct !== 'boolean')
|
|
692
762
|
throw new TypeError('Pass direct as true or false.');
|
|
693
763
|
const directOn = o.direct === true;
|
|
@@ -745,8 +815,10 @@ export function buyTool(options) {
|
|
|
745
815
|
onProgress,
|
|
746
816
|
onStatus: o.onStatus,
|
|
747
817
|
onFound: o.onFound,
|
|
818
|
+
onAvailability: o.onAvailability,
|
|
748
819
|
onProducts: o.onProducts,
|
|
749
820
|
onText: o.onText,
|
|
821
|
+
texting: textingByDefault,
|
|
750
822
|
onCart: o.onCart,
|
|
751
823
|
links: null,
|
|
752
824
|
results: null,
|
|
@@ -779,14 +851,25 @@ export function buyTool(options) {
|
|
|
779
851
|
/** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
|
|
780
852
|
let lastConversationId = null;
|
|
781
853
|
let lastCarts = [];
|
|
782
|
-
/** The
|
|
783
|
-
let
|
|
854
|
+
/** The cart the last answer showed: the cart a confirm sent now confirms. */
|
|
855
|
+
let lastShownCart = null;
|
|
784
856
|
/**
|
|
785
|
-
* The yes the shopper gave in the chat to one cart,
|
|
786
|
-
*
|
|
787
|
-
*
|
|
857
|
+
* The yes the shopper gave in the chat to one cart, when the confirm it sent stopped for a step outside the chat: a card
|
|
858
|
+
* to add or a store to sign in to. A `nextStep` turn confirms the cart once the step is done, as long as it holds the
|
|
859
|
+
* same items at the same store (`sameOrder`): a total that moved because the store priced its fees and tax in keeps the
|
|
860
|
+
* yes, and the turn says the new total. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back,
|
|
861
|
+
* and so does a declined approval.
|
|
788
862
|
*/
|
|
789
863
|
let carriedYes = null;
|
|
864
|
+
/**
|
|
865
|
+
* The go-ahead of the shopper's pick, when they picked what is in the cart and answered only the checkout's questions
|
|
866
|
+
* since (address, phone, name, email, a yes to an address read back). A `nextStep` turn after a saved card or a store
|
|
867
|
+
* sign-in confirms that cart as it stands, when it is the same order (`sameOrder`), whatever the Vault does: the approval
|
|
868
|
+
* link is the shopper's approval. Any other shopper message takes it back, and so do a cart that changed and a declined approval.
|
|
869
|
+
*/
|
|
870
|
+
let pickedOrder = null;
|
|
871
|
+
/** How many shopper messages took a pick's go-ahead back: a call that started before one leaves no pick behind. */
|
|
872
|
+
let pickWithdrawals = 0;
|
|
790
873
|
/** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
|
|
791
874
|
let cardAfterSignIn = null;
|
|
792
875
|
/** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
|
|
@@ -832,7 +915,17 @@ export function buyTool(options) {
|
|
|
832
915
|
* way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
|
|
833
916
|
*/
|
|
834
917
|
const withdrawUnlessGoAhead = (shopperText) => {
|
|
835
|
-
|
|
918
|
+
const text = String(shopperText ?? '');
|
|
919
|
+
const goAhead = keepsYes(text);
|
|
920
|
+
// A pick's go-ahead also stands through the answers the checkout asks for, and through the answer that lets the store
|
|
921
|
+
// cart the pick, such as a size, while no cart holds it yet.
|
|
922
|
+
// Counted with no pick standing too, so a pick still running when the shopper says "no wait" leaves none behind.
|
|
923
|
+
// A short `cancel`, `wait` or `no` answers the question's shape, so it never counts as the answer the pick waits on.
|
|
924
|
+
if (!goAhead && !answersCheckout(text, { nextAction: lastNextAction, asked: lastReply }) && !(pickedOrder?.cart === null && answersQuestion(text, lastReply) && !holdsBack(text))) {
|
|
925
|
+
pickedOrder = null;
|
|
926
|
+
pickWithdrawals++;
|
|
927
|
+
}
|
|
928
|
+
if (goAhead)
|
|
836
929
|
return;
|
|
837
930
|
carriedYes = null;
|
|
838
931
|
unconnectedAsk = null;
|
|
@@ -1320,7 +1413,7 @@ export function buyTool(options) {
|
|
|
1320
1413
|
return;
|
|
1321
1414
|
const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
|
|
1322
1415
|
if (call.confirm)
|
|
1323
|
-
carriedYes = outside
|
|
1416
|
+
carriedYes = outside ? yesAfter(result, call.yes) : null;
|
|
1324
1417
|
if (result.next_action === 'store_login' && turn?.add_card_url)
|
|
1325
1418
|
cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
|
|
1326
1419
|
else if (result.next_action !== 'store_login')
|
|
@@ -1328,6 +1421,45 @@ export function buyTool(options) {
|
|
|
1328
1421
|
if (!call.confirm && call.ask !== '')
|
|
1329
1422
|
unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
|
|
1330
1423
|
};
|
|
1424
|
+
/**
|
|
1425
|
+
* What a shopper's own call leaves of a pick's go-ahead (`pickedOrder`). A pick, a tap on a product's card or a request
|
|
1426
|
+
* to buy it, that leaves one cart and no list of products to choose from starts one, with that cart; one whose store still
|
|
1427
|
+
* asks a question before it carts the pick starts one with no cart yet. A later answer keeps it while the cart stays the
|
|
1428
|
+
* same order, and notes the cart as the chat shows it now; an answer whose cart changed, another purchase, more than one
|
|
1429
|
+
* cart, or a confirm of the shopper's own ends it. `heard` is false for a call whose shopper message no turn read
|
|
1430
|
+
* (`withdrawUnlessGoAhead`): its words must then answer the checkout too.
|
|
1431
|
+
*/
|
|
1432
|
+
const notePick = (result, call) => {
|
|
1433
|
+
if (call.pickWithdrawalsAtStart !== pickWithdrawals)
|
|
1434
|
+
return;
|
|
1435
|
+
const now = result.cart ?? (result.carts.length === 1 ? result.carts[0] : null);
|
|
1436
|
+
if (call.picking) {
|
|
1437
|
+
// A search that listed products beside a cart already there picked nothing in it; one that carted what it names did.
|
|
1438
|
+
const listed = shownProducts(result.products).length > 0;
|
|
1439
|
+
pickedOrder = result.carts.length > 1
|
|
1440
|
+
? null
|
|
1441
|
+
: now && (!listed || call.added)
|
|
1442
|
+
? { conversationId: result.conversation_id, cart: now }
|
|
1443
|
+
: !now && !listed && result.error_code === null
|
|
1444
|
+
? { conversationId: result.conversation_id, cart: null }
|
|
1445
|
+
: null;
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const picked = pickedOrder;
|
|
1449
|
+
if (!picked)
|
|
1450
|
+
return;
|
|
1451
|
+
if (call.confirm || result.carts.length > 1 || (picked.conversationId && result.conversation_id && result.conversation_id !== picked.conversationId)) {
|
|
1452
|
+
pickedOrder = null;
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (!call.heard && !keepsYes(call.ask) && !answersCheckout(call.ask, { nextAction: call.asked.nextAction, asked: call.asked.reply })) {
|
|
1456
|
+
pickedOrder = null;
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (!now)
|
|
1460
|
+
return;
|
|
1461
|
+
pickedOrder = picked.cart === null || sameOrder(now, picked.cart) ? { conversationId: picked.conversationId ?? result.conversation_id, cart: now } : null;
|
|
1462
|
+
};
|
|
1331
1463
|
/** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
|
|
1332
1464
|
const stepResult = (fields) => ({
|
|
1333
1465
|
messages: [],
|
|
@@ -1369,6 +1501,7 @@ export function buyTool(options) {
|
|
|
1369
1501
|
const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
|
|
1370
1502
|
if (event.kind === 'approval_declined') {
|
|
1371
1503
|
carriedYes = null;
|
|
1504
|
+
pickedOrder = null;
|
|
1372
1505
|
return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
|
|
1373
1506
|
}
|
|
1374
1507
|
if (event.kind === 'card_added' && unconnectedAsk) {
|
|
@@ -1382,9 +1515,28 @@ export function buyTool(options) {
|
|
|
1382
1515
|
cardAfterSignIn = null;
|
|
1383
1516
|
return { local: stepResult({ ...standing, status: 'needs_card', reply: CARD_AFTER_SIGN_IN, links: [{ kind: 'vault', url: link.url, expiresAt: link.expiresAt }], next_action: 'add_card' }) };
|
|
1384
1517
|
}
|
|
1385
|
-
|
|
1518
|
+
// The shopper's yes goes on past the step they just finished: the same items at the same store, in the same purchase,
|
|
1519
|
+
// confirmed by the cart's hash as it stands now. A total that moved because the store priced its fees and tax in keeps
|
|
1520
|
+
// the yes, and the answer says the new total beside the next ask; the approval link is still the shopper's consent to
|
|
1521
|
+
// the payment (Karen, 2026-10-27).
|
|
1522
|
+
const yes = carriedYes;
|
|
1523
|
+
const inPurchase = (at) => at === null || at === conversationId;
|
|
1524
|
+
if (yes && one && conversationId && inPurchase(yes.conversationId) && sameOrder(one, yes.cart)) {
|
|
1386
1525
|
carriedYes = null;
|
|
1387
|
-
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
|
|
1526
|
+
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { ...yes, cart: one, conversationId } };
|
|
1527
|
+
}
|
|
1528
|
+
// The shopper picked what is in the cart and finished the step outside the chat: finishing it is their go-ahead for the
|
|
1529
|
+
// same order, confirmed by the cart's hash as it stands now, and the approval link stays their consent to the payment
|
|
1530
|
+
// (Karen, 2026-10-27: "we are again asking for confirmation after user adds a card. we should assume they want to move
|
|
1531
|
+
// forward"). That holds whatever the shopper's Vault does: the approval link is the approval (Karen, 2026-10-27: "That
|
|
1532
|
+
// is the goal of the approval link. You shouldn't be asking again. Take the approval from the approval link as the
|
|
1533
|
+
// approval.").
|
|
1534
|
+
const picked = pickedOrder;
|
|
1535
|
+
if (picked?.cart && one && conversationId && inPurchase(picked.conversationId) && (event.kind === 'card_added' || event.kind === 'store_connected')) {
|
|
1536
|
+
pickedOrder = null;
|
|
1537
|
+
if (sameOrder(one, picked.cart)) {
|
|
1538
|
+
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { conversationId, cart: one, totalCents: picked.cart.totalCents, picked: true } };
|
|
1539
|
+
}
|
|
1388
1540
|
}
|
|
1389
1541
|
// A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
|
|
1390
1542
|
// it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
|
|
@@ -1441,13 +1593,22 @@ export function buyTool(options) {
|
|
|
1441
1593
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1442
1594
|
};
|
|
1443
1595
|
// The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
|
|
1444
|
-
const
|
|
1596
|
+
const yesShown = confirm ? lastShownCart : null;
|
|
1597
|
+
// A pick of what the shopper saw, by a product's id or its name, or a request to buy something: when its answer carts
|
|
1598
|
+
// it, the shopper's go-ahead for that cart (`notePick`). Read before the answer replaces what the shopper saw.
|
|
1599
|
+
const picksShown = !confirm && (!!pick?.product_id || pickOf(ask) !== null || isProductAsk(ask));
|
|
1600
|
+
const cartBefore = lastShownCart;
|
|
1601
|
+
const pickWithdrawalsAtStart = pickWithdrawals;
|
|
1602
|
+
const askedBefore = { nextAction: lastNextAction, reply: lastReply };
|
|
1445
1603
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
1446
1604
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
1447
1605
|
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
1606
|
+
// Whether a turn read the shopper's message (`withdrawUnlessGoAhead`): `buy.turn`, or the app's instant reply.
|
|
1607
|
+
let heard = !!explicit;
|
|
1448
1608
|
if (!explicit) {
|
|
1449
1609
|
const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
|
|
1450
1610
|
appAck = null;
|
|
1611
|
+
heard = !!opened;
|
|
1451
1612
|
if (opened) {
|
|
1452
1613
|
// Only a reply the app sent quiets onWorking. A message such as `yes` that starts a slow search
|
|
1453
1614
|
// still gets a reply that names nothing, and a slow pick gets one that says it is on it.
|
|
@@ -1476,6 +1637,8 @@ export function buyTool(options) {
|
|
|
1476
1637
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1477
1638
|
// fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
|
|
1478
1639
|
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1640
|
+
/** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
|
|
1641
|
+
let settleLeft = null;
|
|
1479
1642
|
try {
|
|
1480
1643
|
let turn = null;
|
|
1481
1644
|
let refusal;
|
|
@@ -1486,11 +1649,68 @@ export function buyTool(options) {
|
|
|
1486
1649
|
* and every message after it, so the shopper reads them in the order Agentcard wrote them.
|
|
1487
1650
|
*/
|
|
1488
1651
|
const waiting = [];
|
|
1652
|
+
/** True when the last message the shopper read ended in a colon or a question mark, so choices that open the next one are its list. */
|
|
1653
|
+
let leadIn = false;
|
|
1654
|
+
/** Shows one of Agentcard's messages through onText, its lists as bullets (messageForShopper); `told` keeps it as Agentcard wrote it. */
|
|
1655
|
+
const showText = async (text, conversationId) => {
|
|
1656
|
+
await t.onText(messageForShopper(text, leadIn, t.texting), { conversationId });
|
|
1657
|
+
leadIn = leadsIntoList(text);
|
|
1658
|
+
told.push(text);
|
|
1659
|
+
};
|
|
1489
1660
|
/** The products past the cards of the catalog this call heard last, for the count that follows it. */
|
|
1490
1661
|
let moreFound = null;
|
|
1491
1662
|
/** Whether this call's cards went out, and a count that came before them. */
|
|
1492
1663
|
let cardsOut = false;
|
|
1493
1664
|
let heldCount = null;
|
|
1665
|
+
/** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
|
|
1666
|
+
const cardOfId = new Map();
|
|
1667
|
+
const checking = new Map();
|
|
1668
|
+
/**
|
|
1669
|
+
* What the live search answered for each card, so a later copy of the same place, such as a card whose first send
|
|
1670
|
+
* failed and goes out again with the answer, carries that answer and never goes back to `checking`.
|
|
1671
|
+
*/
|
|
1672
|
+
const answered = new Map();
|
|
1673
|
+
const withAnswers = (products) => answered.size === 0 ? products : products.map((product) => {
|
|
1674
|
+
const known = product.availability ? answered.get(pickId(product) ?? '') : undefined;
|
|
1675
|
+
return known ? { ...product, availability: known } : product;
|
|
1676
|
+
});
|
|
1677
|
+
/**
|
|
1678
|
+
* Notes the card each product goes out as, and each place still `checking`, whichever way the app got it: a card
|
|
1679
|
+
* through `sendLink` now or on a later try, or a product past the cards that `onFound` hands over for "show more".
|
|
1680
|
+
*/
|
|
1681
|
+
const noteCards = (products, conversationId) => {
|
|
1682
|
+
for (const product of products) {
|
|
1683
|
+
const productId = pickId(product);
|
|
1684
|
+
if (!productId)
|
|
1685
|
+
continue;
|
|
1686
|
+
cardOfId.set(product.id, productId);
|
|
1687
|
+
if (product.availability?.status === 'checking' && !answered.has(productId))
|
|
1688
|
+
checking.set(productId, conversationId);
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
/** Hands the app what the live search said of the places, each by its card, and settles those cards. */
|
|
1692
|
+
settleLeft = async () => {
|
|
1693
|
+
if (!t.onAvailability || !early)
|
|
1694
|
+
return;
|
|
1695
|
+
const left = [...checking];
|
|
1696
|
+
for (const conversationId of new Set(left.map(([, c]) => c))) {
|
|
1697
|
+
await tellAvailability(conversationId, left.filter(([, c]) => c === conversationId).map(([productId]) => ({ productId, availability: { status: 'unknown' } })));
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
const tellAvailability = async (conversationId, items) => {
|
|
1701
|
+
for (const item of items) {
|
|
1702
|
+
checking.delete(item.productId);
|
|
1703
|
+
answered.set(item.productId, item.availability);
|
|
1704
|
+
}
|
|
1705
|
+
if (!t.onAvailability || items.length === 0)
|
|
1706
|
+
return;
|
|
1707
|
+
try {
|
|
1708
|
+
await t.onAvailability({ conversationId, items });
|
|
1709
|
+
}
|
|
1710
|
+
catch {
|
|
1711
|
+
// The app's display failing never fails the call.
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1494
1714
|
/** Hands the app one count with the products past the cards of the catalog it counts. */
|
|
1495
1715
|
const tellCount = async ({ conversationId, shown, found }) => {
|
|
1496
1716
|
const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
|
|
@@ -1546,7 +1766,9 @@ export function buyTool(options) {
|
|
|
1546
1766
|
ack?.cancel();
|
|
1547
1767
|
await t.ackOut;
|
|
1548
1768
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
1549
|
-
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
|
|
1769
|
+
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1770
|
+
// A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
|
|
1771
|
+
noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
|
|
1550
1772
|
keepMore();
|
|
1551
1773
|
cardsOut = true;
|
|
1552
1774
|
// A count held for these cards goes out now, unless the catalog carries its own, which follows it.
|
|
@@ -1557,6 +1779,13 @@ export function buyTool(options) {
|
|
|
1557
1779
|
},
|
|
1558
1780
|
}
|
|
1559
1781
|
: {}),
|
|
1782
|
+
...(t.onAvailability && early
|
|
1783
|
+
? {
|
|
1784
|
+
onAvailability: async ({ conversationId, items }) => {
|
|
1785
|
+
await tellAvailability(conversationId, items.map(({ id, availability }) => ({ productId: cardOfId.get(id) ?? id, availability })));
|
|
1786
|
+
},
|
|
1787
|
+
}
|
|
1788
|
+
: {}),
|
|
1560
1789
|
...(t.onText
|
|
1561
1790
|
? {
|
|
1562
1791
|
onSegment: async ({ text, conversationId }) => {
|
|
@@ -1569,8 +1798,7 @@ export function buyTool(options) {
|
|
|
1569
1798
|
return;
|
|
1570
1799
|
}
|
|
1571
1800
|
try {
|
|
1572
|
-
await
|
|
1573
|
-
told.push(text);
|
|
1801
|
+
await showText(text, conversationId);
|
|
1574
1802
|
}
|
|
1575
1803
|
catch {
|
|
1576
1804
|
// A message the app failed to show was never read, so the model words the answer.
|
|
@@ -1633,8 +1861,7 @@ export function buyTool(options) {
|
|
|
1633
1861
|
continue;
|
|
1634
1862
|
}
|
|
1635
1863
|
try {
|
|
1636
|
-
await
|
|
1637
|
-
told.push(message.text);
|
|
1864
|
+
await showText(message.text, message.conversationId);
|
|
1638
1865
|
}
|
|
1639
1866
|
catch {
|
|
1640
1867
|
// A message the app failed to show was never read, so the model words the answer.
|
|
@@ -1643,8 +1870,7 @@ export function buyTool(options) {
|
|
|
1643
1870
|
const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
|
|
1644
1871
|
if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
|
|
1645
1872
|
try {
|
|
1646
|
-
await
|
|
1647
|
-
told.push(stepAsk);
|
|
1873
|
+
await showText(stepAsk, settled.conversation_id);
|
|
1648
1874
|
}
|
|
1649
1875
|
catch {
|
|
1650
1876
|
// Not shown, so the model asks for the step.
|
|
@@ -1653,22 +1879,50 @@ export function buyTool(options) {
|
|
|
1653
1879
|
}
|
|
1654
1880
|
// Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
|
|
1655
1881
|
// 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
|
-
|
|
1657
|
-
|
|
1882
|
+
// A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
|
|
1883
|
+
// by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
|
|
1884
|
+
let choiceShown = true;
|
|
1885
|
+
if (t.onText && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
|
|
1886
|
+
const lines = choiceLines(turn.choice);
|
|
1887
|
+
try {
|
|
1888
|
+
await t.onText(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
|
|
1889
|
+
}
|
|
1890
|
+
catch {
|
|
1891
|
+
// Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
|
|
1892
|
+
choiceShown = false;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
|
|
1896
|
+
const answer = withMessages(settled, turn);
|
|
1897
|
+
const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1658
1898
|
// A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
|
|
1659
1899
|
if (heldCount && (delivered.products?.length ?? 0) > 0) {
|
|
1660
1900
|
const held = heldCount;
|
|
1661
1901
|
heldCount = null;
|
|
1662
1902
|
await tellCount(held);
|
|
1663
1903
|
}
|
|
1904
|
+
// A call that ends with cards still `checking`, the answer's own cards included, says `unknown` of each, so none waits
|
|
1905
|
+
// for an answer that is not coming.
|
|
1906
|
+
if (delivered.conversation_id)
|
|
1907
|
+
noteCards(delivered.products ?? [], delivered.conversation_id);
|
|
1908
|
+
await settleLeft();
|
|
1664
1909
|
// 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
1910
|
// 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
1911
|
if (read)
|
|
1667
1912
|
await t.releaseNow?.();
|
|
1913
|
+
// A confirm the turn sent on the shopper's earlier yes names the total they said yes to beside the cart's total now. A
|
|
1914
|
+
// confirm Agentcard refused, or one whose answer holds another order or asks for a yes, carried nothing: the model asks.
|
|
1915
|
+
const carried = plan && 'body' in plan && plan.yes && turn && delivered.next_action !== 'confirm' ? plan.yes : null;
|
|
1916
|
+
const nowCart = !carried ? null : !delivered.cart ? carried.cart : sameOrder(delivered.cart, carried.cart) ? delivered.cart : null;
|
|
1917
|
+
const yesCarried = carried && nowCart
|
|
1918
|
+
? { said_yes_to_cents: carried.totalCents, total_cents: nowCart.totalCents, currency: nowCart.currency ?? null, ...(carried.picked ? { picked: true } : {}) }
|
|
1919
|
+
: null;
|
|
1668
1920
|
const result = {
|
|
1669
1921
|
...delivered,
|
|
1670
1922
|
...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
|
|
1671
1923
|
...(read ? { shopper_already_read_reply: true } : {}),
|
|
1924
|
+
...(confirmSent ? { confirm_sent: true } : {}),
|
|
1925
|
+
...(yesCarried ? { yes_carried: yesCarried } : {}),
|
|
1672
1926
|
};
|
|
1673
1927
|
lastNextAction = result.next_action;
|
|
1674
1928
|
lastReply = result.reply;
|
|
@@ -1681,12 +1935,19 @@ export function buyTool(options) {
|
|
|
1681
1935
|
if (result.conversation_id)
|
|
1682
1936
|
lastConversationId = result.conversation_id;
|
|
1683
1937
|
if (result.cart?.hash)
|
|
1684
|
-
|
|
1938
|
+
lastShownCart = result.cart;
|
|
1685
1939
|
// A shopper's own call, or the shopper's yes or words a turn taken on its own sent, leaves what the next step needs.
|
|
1686
1940
|
if (!t.event)
|
|
1687
|
-
noteShopperStep(result, turn, { confirm: !!confirm,
|
|
1941
|
+
noteShopperStep(result, turn, { confirm: !!confirm, yes: { shown: yesShown }, ask, withdrawalsAtStart });
|
|
1688
1942
|
else if (plan && 'body' in plan)
|
|
1689
|
-
noteShopperStep(result, turn, { confirm: confirmSent,
|
|
1943
|
+
noteShopperStep(result, turn, { confirm: confirmSent && !!plan.yes, yes: plan.yes ? { carried: plan.yes } : { shown: null }, ask: sent.ask ?? '', withdrawalsAtStart });
|
|
1944
|
+
if (!t.event && turn) {
|
|
1945
|
+
// Words that name something to buy and put a new item in the cart picked it too, as `the chicken pesto caprese from
|
|
1946
|
+
// mendocino farms` carts the dish it names.
|
|
1947
|
+
const added = result.pick?.in_cart === true || addsItem(result.cart, cartBefore);
|
|
1948
|
+
const picking = !confirm && (picksShown || result.pick?.in_cart === true || (isNewRequest(ask) && added));
|
|
1949
|
+
notePick(result, { picking, added, confirm: !!confirm, ask, heard, asked: askedBefore, pickWithdrawalsAtStart });
|
|
1950
|
+
}
|
|
1690
1951
|
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
1691
1952
|
const shown = shownProducts(result.products);
|
|
1692
1953
|
if (shown.length > 0)
|
|
@@ -1709,6 +1970,8 @@ export function buyTool(options) {
|
|
|
1709
1970
|
return result;
|
|
1710
1971
|
}
|
|
1711
1972
|
finally {
|
|
1973
|
+
// A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
|
|
1974
|
+
await settleLeft?.().catch(() => { });
|
|
1712
1975
|
ack?.cancel();
|
|
1713
1976
|
running -= 1;
|
|
1714
1977
|
lastEndedAt = Date.now();
|
|
@@ -1782,7 +2045,7 @@ export function buyTool(options) {
|
|
|
1782
2045
|
const own = options ?? {};
|
|
1783
2046
|
if (typeof own !== 'object')
|
|
1784
2047
|
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
1785
|
-
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
2048
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
1786
2049
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1787
2050
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1788
2051
|
}
|
|
@@ -1797,6 +2060,9 @@ export function buyTool(options) {
|
|
|
1797
2060
|
if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
|
|
1798
2061
|
throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
|
|
1799
2062
|
const written = own.replyAsWritten ?? writtenByDefault;
|
|
2063
|
+
if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
|
|
2064
|
+
throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
|
|
2065
|
+
const texting = own.textingStyle ?? textingByDefault;
|
|
1800
2066
|
const hearText = own.onText ?? o.onText;
|
|
1801
2067
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1802
2068
|
const pauseOf = paceOf(own.pace);
|
|
@@ -1861,10 +2127,12 @@ export function buyTool(options) {
|
|
|
1861
2127
|
onProgress: own.onProgress ?? onProgress,
|
|
1862
2128
|
onStatus: own.onStatus ?? o.onStatus,
|
|
1863
2129
|
onFound: own.onFound ?? o.onFound,
|
|
2130
|
+
onAvailability: own.onAvailability ?? o.onAvailability,
|
|
1864
2131
|
onProducts: own.onProducts ?? o.onProducts,
|
|
1865
2132
|
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
1866
2133
|
// an app that passes onItem too hears it once.
|
|
1867
2134
|
onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
|
|
2135
|
+
texting,
|
|
1868
2136
|
onCart: own.onCart ?? o.onCart,
|
|
1869
2137
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1870
2138
|
links: [],
|
|
@@ -1933,9 +2201,13 @@ export function buyTool(options) {
|
|
|
1933
2201
|
const results = state.results;
|
|
1934
2202
|
// 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
2203
|
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2204
|
+
// A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
|
|
2205
|
+
// straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
|
|
2206
|
+
const direct = !directOn || running > 0
|
|
2207
|
+
? null
|
|
2208
|
+
: event
|
|
2209
|
+
? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
|
|
2210
|
+
: directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores });
|
|
1939
2211
|
if (!event)
|
|
1940
2212
|
previousTurn = state;
|
|
1941
2213
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
@@ -1996,7 +2268,7 @@ export function buyTool(options) {
|
|
|
1996
2268
|
links: { get: () => [...links], enumerable: false },
|
|
1997
2269
|
results: { get: () => [...results], enumerable: false },
|
|
1998
2270
|
direct: { value: direct, enumerable: false },
|
|
1999
|
-
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
2271
|
+
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
|
|
2000
2272
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
2001
2273
|
// 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.
|
|
2002
2274
|
// A step link held for this message goes out right after its last paragraph, once the stream ends.
|