@agent-cards/commerce-sdk 0.11.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 +167 -10
- package/dist/ack.d.ts +36 -0
- package/dist/ack.js +36 -8
- package/dist/action-links.d.ts +64 -0
- package/dist/action-links.js +77 -0
- package/dist/browser.d.ts +106 -0
- package/dist/browser.js +118 -0
- package/dist/buy-tool.d.ts +218 -26
- package/dist/buy-tool.js +735 -43
- package/dist/client.d.ts +155 -4
- package/dist/client.js +225 -24
- package/dist/definition.js +25 -2
- 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 +7 -5
- package/dist/index.js +6 -4
- package/dist/message.d.ts +4 -1
- package/dist/messages.d.ts +9 -9
- package/dist/messages.js +6 -6
- package/dist/model-view.d.ts +66 -0
- package/dist/model-view.js +207 -5
- package/dist/shopper-message.d.ts +16 -1
- package/dist/shopper-message.js +11 -0
- package/package.json +13 -1
package/dist/buy-tool.js
CHANGED
|
@@ -31,11 +31,20 @@
|
|
|
31
31
|
* messages and links as one sequence, in the order they happened, and spaces
|
|
32
32
|
* the messages like someone texting.
|
|
33
33
|
*/
|
|
34
|
-
import { AgentcardApiError, AgentcardClient, } from './client.js';
|
|
34
|
+
import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
|
|
35
35
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
36
|
-
import {
|
|
36
|
+
import { directCall } from './direct.js';
|
|
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
|
-
import {
|
|
39
|
+
import { isStale } from './shopper-message.js';
|
|
40
|
+
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
|
+
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
43
|
+
export function productAction(merchant) {
|
|
44
|
+
if (merchant === 'reservations')
|
|
45
|
+
return 'reserve';
|
|
46
|
+
return merchant === 'flights' || merchant === 'hotels' || merchant === 'tickets' || merchant === 'classes' ? 'book' : 'buy';
|
|
47
|
+
}
|
|
39
48
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
49
|
/**
|
|
41
50
|
* The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
|
|
@@ -112,7 +121,18 @@ function productWords(text) {
|
|
|
112
121
|
}
|
|
113
122
|
/** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
|
|
114
123
|
const APP_TURN_MS = 120_000;
|
|
124
|
+
/** The links that ask the shopper to take a step: they go out after the message that asks for it. */
|
|
125
|
+
const STEP_LINK_KINDS = new Set(['vault', 'approval', 'store_login']);
|
|
126
|
+
/** The longest a step link waits for its message: an app that never writes one still sends the link. */
|
|
127
|
+
const STEP_LINK_HOLD_MS = 20_000;
|
|
115
128
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
129
|
+
const STILL_BUILDING = "The store is still building the cart for the shopper's last request. Its cart and total reach the shopper in a message from Agentcard: once that message has arrived, call this tool again with the shopper's words.";
|
|
130
|
+
const CART_NOT_READY = 'The store is still building the cart, so it has no total yet, and nothing was confirmed. Its cart and total reach the shopper in a message from Agentcard: confirm it only after the shopper says yes to that total.';
|
|
131
|
+
/** What the shopper hears while their message waits for the cart the store is still building. */
|
|
132
|
+
const stillBuildingLine = (store) => `still building your cart at ${store}, i'll get to this as soon as it's in`;
|
|
133
|
+
/** How often, and how many times, a message held for a cart being built reads whether the cart is in. */
|
|
134
|
+
const CART_HOLD_EVERY_MS = 2_000;
|
|
135
|
+
const CART_HOLD_READS = 45;
|
|
116
136
|
const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
|
|
117
137
|
const STILL_PLACING = 'The shopper approved, and the order is still going through.';
|
|
118
138
|
const NOT_PLACED = 'The order did not go through.';
|
|
@@ -125,6 +145,40 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
|
|
|
125
145
|
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.';
|
|
126
146
|
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.";
|
|
127
147
|
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.";
|
|
148
|
+
/** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
|
|
149
|
+
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
|
+
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.';
|
|
151
|
+
const CARD_AFTER_SIGN_IN = 'The shopper is signed in to the store. A card is the next step: ask them to add one from the link.';
|
|
152
|
+
const NO_CART_WAITING = 'No cart is waiting. Carry on with what the shopper last asked for, in one short message.';
|
|
153
|
+
const STEP_APPROVAL_WAITING = 'The shopper already has the link to approve this payment, and nothing was charged yet. Ask for nothing else: in one short line, remind them to approve it from the link they have.';
|
|
154
|
+
const STEP_PLACEMENT_RUNNING = 'The order is going through now. Ask for nothing: say in one short line that it is being placed and that you will tell them once it is.';
|
|
155
|
+
/** What the shopper just did outside the chat, for the line that opens a `nextStep` turn. */
|
|
156
|
+
function stepDone(message) {
|
|
157
|
+
const store = message.facts.merchantName;
|
|
158
|
+
switch (message.kind) {
|
|
159
|
+
case 'card_added':
|
|
160
|
+
return 'The shopper just saved a card outside the chat.';
|
|
161
|
+
case 'store_connected':
|
|
162
|
+
return `The shopper just signed in to ${store ?? 'the store'} outside the chat.`;
|
|
163
|
+
case 'approval_declined':
|
|
164
|
+
return `The shopper just declined the approval${store ? ` for the ${store} order` : ''} outside the chat. Nothing was charged and no order was placed.`;
|
|
165
|
+
default:
|
|
166
|
+
return 'The shopper just finished a step outside the chat.';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The words a `nextStep` turn hands the model as the user's message: the event, marked as no one's
|
|
171
|
+
* typing, and the instruction to take the next step now in one short message, without the filler of
|
|
172
|
+
* a step announced and a question about what comes next.
|
|
173
|
+
*/
|
|
174
|
+
export function nextStepPrompt(message) {
|
|
175
|
+
const note = String(message.note ?? '').trim();
|
|
176
|
+
return [
|
|
177
|
+
note || `[event: ${stepDone(message)} This note was generated by the system, not typed by the user.]`,
|
|
178
|
+
`${stepDone(message)} The shopper has not written anything since: this turn starts on its own. Take the next step now, as your next message to them. Call the buy tool once, with ask "next step", and do what its answer says, in one short message.`,
|
|
179
|
+
"Don't tell them they're all set or ready, and don't ask what's next. Never say an order is placed or a card charged unless the buy tool's answer says so.",
|
|
180
|
+
].join('\n\n');
|
|
181
|
+
}
|
|
128
182
|
/**
|
|
129
183
|
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
130
184
|
* the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
|
|
@@ -144,6 +198,11 @@ export function productsOf(turn) {
|
|
|
144
198
|
return turn.catalog ? productsOfCatalog(turn.catalog) : [];
|
|
145
199
|
}
|
|
146
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
|
+
}
|
|
147
206
|
function productsOfCatalog(catalog) {
|
|
148
207
|
return (catalog.items ?? [])
|
|
149
208
|
.filter((item) => item.name)
|
|
@@ -152,11 +211,14 @@ function productsOfCatalog(catalog) {
|
|
|
152
211
|
ref: item.ref,
|
|
153
212
|
name: item.name,
|
|
154
213
|
priceCents: item.priceCents,
|
|
214
|
+
...(item.price_from ? { priceFrom: true } : {}),
|
|
155
215
|
currency: item.currency,
|
|
156
216
|
imageUrl: item.image_url,
|
|
217
|
+
images: item.image_urls ?? (item.image_url ? [item.image_url] : []),
|
|
157
218
|
...(item.flight ? { flight: item.flight } : {}),
|
|
158
219
|
store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
|
|
159
220
|
url: item.url ?? null,
|
|
221
|
+
action: productAction(catalog.merchant),
|
|
160
222
|
linkSent: false,
|
|
161
223
|
}));
|
|
162
224
|
}
|
|
@@ -170,6 +232,14 @@ function storeLoginOf(turn) {
|
|
|
170
232
|
return [];
|
|
171
233
|
return [{ kind: 'store_login', url: turn.store_login_url, expiresAt: turn.store_login_expires_at, store: turn.store_login_merchant_name }];
|
|
172
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* The approval link for a cart, naming the cart's store in `store` when the answer names one, so a
|
|
237
|
+
* message about another store's cart never ends this approval (`actionLinkOutcome`).
|
|
238
|
+
*/
|
|
239
|
+
function approvalLink(url, expiresAt, cart) {
|
|
240
|
+
const store = cart?.merchant_name;
|
|
241
|
+
return { kind: 'approval', url, expiresAt, ...(store ? { store } : {}) };
|
|
242
|
+
}
|
|
173
243
|
/** The answer before its status is read: every field the purchase answer carries. */
|
|
174
244
|
function answerOf(turn) {
|
|
175
245
|
return {
|
|
@@ -192,6 +262,7 @@ function answerOf(turn) {
|
|
|
192
262
|
stores: storesOf(turn.stores),
|
|
193
263
|
store_pick: turn.store_pick,
|
|
194
264
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
265
|
+
cart_building: turn.cart_building,
|
|
195
266
|
envelope: turn.raw,
|
|
196
267
|
};
|
|
197
268
|
}
|
|
@@ -234,9 +305,67 @@ function withMessages(result, turn) {
|
|
|
234
305
|
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
235
306
|
return { ...result, messages: sent.flatMap(toMessages) };
|
|
236
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
|
+
}
|
|
237
366
|
/** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
|
|
238
367
|
function resultOfTurn(turn) {
|
|
239
|
-
return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
|
|
368
|
+
return askingForNamedStep(onlyNextStepLinks(resultOfStatus(turn), turn.next_action), turn.next_action);
|
|
240
369
|
}
|
|
241
370
|
/** A purchase answer, read for its status, with every link the answer carries. */
|
|
242
371
|
function resultOfStatus(turn) {
|
|
@@ -259,7 +388,7 @@ function resultOfStatus(turn) {
|
|
|
259
388
|
return {
|
|
260
389
|
...answer,
|
|
261
390
|
status: 'approval_sent',
|
|
262
|
-
links: [...answer.links, ...(turn.approval_url ? [
|
|
391
|
+
links: [...answer.links, ...(turn.approval_url ? [approvalLink(turn.approval_url, turn.approval_expires_at, turn.cart)] : [])],
|
|
263
392
|
shopper_need_not_reply: turn.placement_announced === true,
|
|
264
393
|
};
|
|
265
394
|
}
|
|
@@ -292,16 +421,19 @@ function resultOfRefusal(error) {
|
|
|
292
421
|
stores: [],
|
|
293
422
|
store_pick: null,
|
|
294
423
|
hashes: hashesOf(null, carts),
|
|
424
|
+
cart_building: cartBuildingOf(error.body.cart_building),
|
|
295
425
|
envelope: error.body,
|
|
296
426
|
});
|
|
297
427
|
if (error.code.startsWith('delivery_address'))
|
|
298
428
|
return answer(ADDRESS_CHANGED, []);
|
|
429
|
+
if (error.code === 'cart_not_ready')
|
|
430
|
+
return answer(CART_NOT_READY, []);
|
|
299
431
|
if (error.code === 'cart_changed')
|
|
300
432
|
return answer(CART_CHANGED, error.carts);
|
|
301
433
|
if (error.code === 'nothing_to_confirm' || error.code === 'conversation_closed')
|
|
302
434
|
return answer(NOTHING_TO_CONFIRM, []);
|
|
303
435
|
if (error.code === 'turn_in_progress')
|
|
304
|
-
return answer(STILL_WORKING, []);
|
|
436
|
+
return answer(cartBuildingOf(error.body.cart_building) ? STILL_BUILDING : STILL_WORKING, []);
|
|
305
437
|
if (error.code === 'pick_not_found')
|
|
306
438
|
return answer(PICK_GONE, []);
|
|
307
439
|
if (error.code === 'store_pick_not_found')
|
|
@@ -435,15 +567,29 @@ function feedReply(text) {
|
|
|
435
567
|
};
|
|
436
568
|
return { add, settled };
|
|
437
569
|
}
|
|
438
|
-
/**
|
|
439
|
-
|
|
570
|
+
/**
|
|
571
|
+
* A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. `ended` runs
|
|
572
|
+
* once the last message is cut and has its place in the feed, before it went out, so what it adds
|
|
573
|
+
* follows the reply. `end` settles once the reply and what `ended` added went out, and rejects with
|
|
574
|
+
* the first failure.
|
|
575
|
+
*/
|
|
576
|
+
function feedStream(text, ended) {
|
|
440
577
|
const reply = feedReply(text);
|
|
441
578
|
const inner = messageStream({ onMessage: reply.add, pace: false });
|
|
442
579
|
return {
|
|
443
580
|
push: (delta) => inner.push(delta),
|
|
444
581
|
end: async () => {
|
|
445
|
-
|
|
582
|
+
let added = Promise.resolve();
|
|
583
|
+
try {
|
|
584
|
+
await inner.end();
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
added = ended();
|
|
588
|
+
// Read below, after the reply: a failure here is never left unhandled while the reply settles.
|
|
589
|
+
added.catch(() => { });
|
|
590
|
+
}
|
|
446
591
|
await reply.settled();
|
|
592
|
+
await added;
|
|
447
593
|
},
|
|
448
594
|
};
|
|
449
595
|
}
|
|
@@ -518,17 +664,33 @@ export function buyTool(options) {
|
|
|
518
664
|
const sendLink = o.sendLink;
|
|
519
665
|
if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
|
|
520
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.');
|
|
521
673
|
if (o.stream !== undefined && o.stream !== false)
|
|
522
674
|
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
523
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
|
+
}
|
|
524
680
|
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
525
681
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
526
|
-
// A
|
|
527
|
-
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;
|
|
528
684
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
529
685
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
530
686
|
}
|
|
531
687
|
const onResult = o.onResult;
|
|
688
|
+
if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
|
|
689
|
+
throw new TypeError('Pass replyAsWritten as true or false.');
|
|
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;
|
|
532
694
|
const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
533
695
|
if (typeof wait !== 'function')
|
|
534
696
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
@@ -578,12 +740,19 @@ export function buyTool(options) {
|
|
|
578
740
|
sendLink,
|
|
579
741
|
onWorking,
|
|
580
742
|
onReaction,
|
|
743
|
+
tell: o.onWorking,
|
|
581
744
|
onResult,
|
|
582
745
|
onProgress,
|
|
746
|
+
onStatus: o.onStatus,
|
|
747
|
+
onFound: o.onFound,
|
|
748
|
+
onProducts: o.onProducts,
|
|
749
|
+
onText: o.onText,
|
|
750
|
+
onCart: o.onCart,
|
|
583
751
|
links: null,
|
|
584
752
|
results: null,
|
|
585
753
|
pages: null,
|
|
586
754
|
storePages: null,
|
|
755
|
+
event: null,
|
|
587
756
|
});
|
|
588
757
|
/**
|
|
589
758
|
* The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
|
|
@@ -600,12 +769,46 @@ export function buyTool(options) {
|
|
|
600
769
|
let lastNextAction = null;
|
|
601
770
|
/** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
|
|
602
771
|
let lastReply = null;
|
|
772
|
+
/** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
|
|
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;
|
|
779
|
+
/** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
|
|
780
|
+
let lastConversationId = null;
|
|
781
|
+
let lastCarts = [];
|
|
782
|
+
/** The hash of the cart the last answer showed: the cart a confirm sent now confirms. */
|
|
783
|
+
let lastShownHash = null;
|
|
784
|
+
/**
|
|
785
|
+
* The yes the shopper gave in the chat to one cart, by its hash, when the confirm it sent stopped for a
|
|
786
|
+
* step outside the chat: a card to add or a store to sign in to. A `nextStep` turn confirms that cart
|
|
787
|
+
* once the step is done. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back.
|
|
788
|
+
*/
|
|
789
|
+
let carriedYes = null;
|
|
790
|
+
/** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
|
|
791
|
+
let cardAfterSignIn = null;
|
|
792
|
+
/** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
|
|
793
|
+
let unconnectedAsk = null;
|
|
794
|
+
/** The events this tool already took a `nextStep` turn for. */
|
|
795
|
+
const stepped = new Set();
|
|
796
|
+
/**
|
|
797
|
+
* How many shopper messages took back what the shopper asked for before them (any message that is not a plain go-ahead).
|
|
798
|
+
* A call that started before one of them leaves no yes and no words for a later step: its answer can land after the
|
|
799
|
+
* shopper said no, and a yes it carried would be confirmed by the next card or sign-in (review of #9220).
|
|
800
|
+
*/
|
|
801
|
+
let withdrawals = 0;
|
|
603
802
|
/** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
|
|
604
803
|
let shownNames = [];
|
|
605
804
|
const namesShownProduct = (text) => {
|
|
606
805
|
const said = productWords(text);
|
|
607
806
|
return said !== '' && shownNames.some((name) => said.includes(name));
|
|
608
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;
|
|
609
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. */
|
|
610
813
|
let shownStoreNames = [];
|
|
611
814
|
/** True for a pick of a product or a store the shopper saw, by its name. */
|
|
@@ -623,8 +826,21 @@ export function buyTool(options) {
|
|
|
623
826
|
lastTemplate = nextTemplate(lastTemplate);
|
|
624
827
|
return { picked, text: said ? ackAt(lastTemplate, text) : null };
|
|
625
828
|
};
|
|
829
|
+
/**
|
|
830
|
+
* A shopper message that is not a plain go-ahead takes back a yes the shopper gave before it, and words of theirs that
|
|
831
|
+
* never ran because they were not connected yet ("never mind" after them): a card saved later replays neither. Every
|
|
832
|
+
* way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
|
|
833
|
+
*/
|
|
834
|
+
const withdrawUnlessGoAhead = (shopperText) => {
|
|
835
|
+
if (keepsYes(String(shopperText ?? '')))
|
|
836
|
+
return;
|
|
837
|
+
carriedYes = null;
|
|
838
|
+
unconnectedAsk = null;
|
|
839
|
+
withdrawals++;
|
|
840
|
+
};
|
|
626
841
|
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
627
842
|
const instantAck = (shopperText) => {
|
|
843
|
+
withdrawUnlessGoAhead(shopperText);
|
|
628
844
|
const { picked, text } = ackOfMessage(shopperText);
|
|
629
845
|
appAck = { at: Date.now(), said: text !== null, picked };
|
|
630
846
|
return text;
|
|
@@ -714,6 +930,40 @@ export function buyTool(options) {
|
|
|
714
930
|
},
|
|
715
931
|
};
|
|
716
932
|
};
|
|
933
|
+
/**
|
|
934
|
+
* A message sent while the store builds the cart the last request asked for (`turn_in_progress` with `cart_building`) is
|
|
935
|
+
* never refused for it: the shopper hears the store is still building their cart, once, and the message waits until
|
|
936
|
+
* the cart is in, reading the conversation every two seconds for at most 90. True once the cart is in and the message
|
|
937
|
+
* can go; false for any other refusal, and when the wait gave up.
|
|
938
|
+
*/
|
|
939
|
+
const holdForCart = async (error, t, quiet) => {
|
|
940
|
+
if (!(error instanceof AgentcardApiError) || error.status !== 409 || error.code !== 'turn_in_progress' || !error.conversationId)
|
|
941
|
+
return false;
|
|
942
|
+
const building = cartBuildingOf(error.body.cart_building);
|
|
943
|
+
if (!building)
|
|
944
|
+
return false;
|
|
945
|
+
// The acknowledgement this message was due goes nowhere: the shopper hears the cart is still building instead, and no
|
|
946
|
+
// search runs meanwhile for it to speak of (Greptile on #9652).
|
|
947
|
+
quiet?.();
|
|
948
|
+
try {
|
|
949
|
+
await t.tell?.(stillBuildingLine(building.store));
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
// The app's display failing never fails the purchase call.
|
|
953
|
+
}
|
|
954
|
+
// The 90 seconds hold however slow a read is: each read gets only the time left, so a read that hangs ends the wait.
|
|
955
|
+
const deadline = Date.now() + CART_HOLD_READS * CART_HOLD_EVERY_MS;
|
|
956
|
+
for (let i = 0; i < CART_HOLD_READS && Date.now() < deadline; i++) {
|
|
957
|
+
await wait(CART_HOLD_EVERY_MS);
|
|
958
|
+
const left = deadline - Date.now();
|
|
959
|
+
if (left <= 0)
|
|
960
|
+
break;
|
|
961
|
+
const conversation = await readWithin(error.conversationId, left);
|
|
962
|
+
if (conversation && !conversation.cart_building && !conversation.turn_in_progress)
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
return false;
|
|
966
|
+
};
|
|
717
967
|
/** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
|
|
718
968
|
const read = async (conversationId) => {
|
|
719
969
|
try {
|
|
@@ -723,6 +973,17 @@ export function buyTool(options) {
|
|
|
723
973
|
return null;
|
|
724
974
|
}
|
|
725
975
|
};
|
|
976
|
+
/** One read that gives up after `ms`, as a failed read does: the read itself is no longer waited for. */
|
|
977
|
+
const readWithin = async (conversationId, ms) => {
|
|
978
|
+
let timer;
|
|
979
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
|
|
980
|
+
try {
|
|
981
|
+
return await Promise.race([read(conversationId), late]);
|
|
982
|
+
}
|
|
983
|
+
finally {
|
|
984
|
+
clearTimeout(timer);
|
|
985
|
+
}
|
|
986
|
+
};
|
|
726
987
|
/** The order this purchase placed, or the reason it stopped when the ledger says it did. */
|
|
727
988
|
const placed = (base, order, conversation) => {
|
|
728
989
|
const conversationId = conversation.conversation_id || base.conversation_id;
|
|
@@ -809,7 +1070,7 @@ export function buyTool(options) {
|
|
|
809
1070
|
...base,
|
|
810
1071
|
status: 'approval_sent',
|
|
811
1072
|
reply: APPROVAL_WAITING,
|
|
812
|
-
links: [
|
|
1073
|
+
links: [approvalLink(last.approval_url, last.approval_expires_at, base.cart)],
|
|
813
1074
|
approval_expires_at: last.approval_expires_at,
|
|
814
1075
|
next_action: 'approve',
|
|
815
1076
|
shopper_need_not_reply: announced,
|
|
@@ -973,9 +1234,7 @@ export function buyTool(options) {
|
|
|
973
1234
|
if (!before.has(key)) {
|
|
974
1235
|
try {
|
|
975
1236
|
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
976
|
-
|
|
977
|
-
// A flight option has no page at a store: its card carries no url, and its productId picks it.
|
|
978
|
-
await send({ kind: 'product', url: product.url || null, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
|
|
1237
|
+
await send(productLink(product));
|
|
979
1238
|
}
|
|
980
1239
|
catch {
|
|
981
1240
|
continue;
|
|
@@ -1037,37 +1296,152 @@ export function buyTool(options) {
|
|
|
1037
1296
|
};
|
|
1038
1297
|
/** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
|
|
1039
1298
|
const deliver = async (result, call, send) => {
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1299
|
+
// Each step link carries its stable id, in what the shopper is sent and in what the call returns.
|
|
1300
|
+
const stamped = result.links.some((link) => isActionLink(link) && !link.id)
|
|
1301
|
+
? { ...result, links: result.links.map((link) => (isActionLink(link) && !link.id ? { ...link, id: actionLinkId(link) } : link)) }
|
|
1302
|
+
: result;
|
|
1303
|
+
let delivered = stamped;
|
|
1304
|
+
if (stamped.links.length > 0 && send) {
|
|
1305
|
+
for (const link of stamped.links)
|
|
1043
1306
|
await send({ ...link });
|
|
1044
|
-
delivered = { ...
|
|
1307
|
+
delivered = { ...stamped, link_sent: true };
|
|
1045
1308
|
}
|
|
1046
1309
|
return deliverPagesInTurn(delivered, call, send);
|
|
1047
1310
|
};
|
|
1311
|
+
/**
|
|
1312
|
+
* What a shopper's own call leaves for a later `nextStep` turn: the yes a confirm gave, while the
|
|
1313
|
+
* confirm stopped for a card or a store sign-in; the add-card link an answer held back while it asked
|
|
1314
|
+
* for the sign-in first; and the words of a shopper who was not connected yet.
|
|
1315
|
+
*/
|
|
1316
|
+
const noteShopperStep = (result, turn, call) => {
|
|
1317
|
+
// A shopper message that took things back while this call ran wins over what the call carried, and a newer call's
|
|
1318
|
+
// state stays as it is: an answer from before the withdrawal changes nothing here (review of #9220).
|
|
1319
|
+
if (call.withdrawalsAtStart !== withdrawals)
|
|
1320
|
+
return;
|
|
1321
|
+
const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
|
|
1322
|
+
if (call.confirm)
|
|
1323
|
+
carriedYes = outside && call.yesHash ? { hash: call.yesHash } : null;
|
|
1324
|
+
if (result.next_action === 'store_login' && turn?.add_card_url)
|
|
1325
|
+
cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
|
|
1326
|
+
else if (result.next_action !== 'store_login')
|
|
1327
|
+
cardAfterSignIn = null;
|
|
1328
|
+
if (!call.confirm && call.ask !== '')
|
|
1329
|
+
unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
|
|
1330
|
+
};
|
|
1331
|
+
/** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
|
|
1332
|
+
const stepResult = (fields) => ({
|
|
1333
|
+
messages: [],
|
|
1334
|
+
cart: null,
|
|
1335
|
+
carts: [],
|
|
1336
|
+
products: [],
|
|
1337
|
+
links: [],
|
|
1338
|
+
link_sent: false,
|
|
1339
|
+
approval_expires_at: null,
|
|
1340
|
+
shopper_need_not_reply: false,
|
|
1341
|
+
conversation_id: null,
|
|
1342
|
+
order_id: null,
|
|
1343
|
+
decline_code: null,
|
|
1344
|
+
error_code: null,
|
|
1345
|
+
next_action: null,
|
|
1346
|
+
pick: null,
|
|
1347
|
+
stores: [],
|
|
1348
|
+
store_pick: null,
|
|
1349
|
+
hashes: [],
|
|
1350
|
+
envelope: {},
|
|
1351
|
+
...fields,
|
|
1352
|
+
});
|
|
1353
|
+
/**
|
|
1354
|
+
* How a call of a `nextStep` turn goes. It never sends the model's ask, which Agentcard could read as
|
|
1355
|
+
* the shopper's go-ahead. It sends the shopper's own words that never ran because they were not
|
|
1356
|
+
* connected yet, once their card is saved, or a confirm of the one cart the shopper already said yes
|
|
1357
|
+
* to, by that cart's hash. Every other call answers from a read of the conversation: the card link the
|
|
1358
|
+
* sign-in held back, the cart and the ask for the shopper's yes, or the way forward after a decline.
|
|
1359
|
+
*/
|
|
1360
|
+
const stepPlan = async (event) => {
|
|
1361
|
+
const conversationId = event.conversationId ?? lastConversationId;
|
|
1362
|
+
const conversation = conversationId ? await read(conversationId) : null;
|
|
1363
|
+
const carts = conversation ? conversation.carts : lastCarts;
|
|
1364
|
+
const one = carts.length === 1 ? carts[0] : null;
|
|
1365
|
+
const who = {
|
|
1366
|
+
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
1367
|
+
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1368
|
+
};
|
|
1369
|
+
const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
|
|
1370
|
+
if (event.kind === 'approval_declined') {
|
|
1371
|
+
carriedYes = null;
|
|
1372
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
|
|
1373
|
+
}
|
|
1374
|
+
if (event.kind === 'card_added' && unconnectedAsk) {
|
|
1375
|
+
const ask = unconnectedAsk;
|
|
1376
|
+
unconnectedAsk = null;
|
|
1377
|
+
// Every purchase through the tool pays from the Vault, so a replayed ask names it too and its carts are quoted that way.
|
|
1378
|
+
return { body: { resume: true, ask, payment_source: 'vault', ...who } };
|
|
1379
|
+
}
|
|
1380
|
+
if (event.kind === 'store_connected' && cardAfterSignIn && !(Date.parse(cardAfterSignIn.expiresAt ?? '') <= Date.now())) {
|
|
1381
|
+
const link = cardAfterSignIn;
|
|
1382
|
+
cardAfterSignIn = null;
|
|
1383
|
+
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
|
+
}
|
|
1385
|
+
if (carriedYes && one && conversationId && one.hash === carriedYes.hash) {
|
|
1386
|
+
carriedYes = null;
|
|
1387
|
+
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
|
|
1388
|
+
}
|
|
1389
|
+
// A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
|
|
1390
|
+
// it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
|
|
1391
|
+
const last = conversation?.last_checkout ?? null;
|
|
1392
|
+
const approvalOpen = !!last?.approval_url && !(Date.parse(last.approval_expires_at ?? '') <= Date.now());
|
|
1393
|
+
// A checkout whose order the store already took (`pending` while the charge settles, with its order id) is reported as
|
|
1394
|
+
// placed, with that order id, as the recovery path does, never as still going through (review of #9220).
|
|
1395
|
+
const order = conversation && last?.order_id ? placedOrderOf(conversation, 0) : null;
|
|
1396
|
+
if (conversation && order)
|
|
1397
|
+
return { local: placed(stepResult({ ...standing, status: 'needs_input', reply: '' }), order, conversation) };
|
|
1398
|
+
if (conversation && placementRunning(conversation))
|
|
1399
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_PLACEMENT_RUNNING }) };
|
|
1400
|
+
if (approvalOpen)
|
|
1401
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_APPROVAL_WAITING, next_action: 'approve' }) };
|
|
1402
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? ASK_FOR_YES : NO_CART_WAITING, next_action: carts.length > 0 ? 'confirm' : null }) };
|
|
1403
|
+
};
|
|
1048
1404
|
/**
|
|
1049
1405
|
* One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
|
|
1050
1406
|
* from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
|
|
1051
1407
|
*/
|
|
1052
1408
|
const answerIn = async (raw, explicit) => {
|
|
1053
1409
|
const startedAt = Date.now();
|
|
1410
|
+
// A step link held for the message that introduces it goes out before this call hands over any of its own.
|
|
1411
|
+
explicit?.release?.();
|
|
1412
|
+
const withdrawalsAtStart = withdrawals;
|
|
1054
1413
|
const input = parseBuyToolInput(raw);
|
|
1055
|
-
//
|
|
1414
|
+
// With carts at more than one store waiting, a new order would drop every one of them: "cancel the
|
|
1415
|
+
// reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
|
|
1416
|
+
// The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
|
|
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;
|
|
1422
|
+
// A new order is never a confirm: a model that sets both drops the cart and places nothing, even
|
|
1423
|
+
// when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
|
|
1056
1424
|
const confirm = input.confirm && !input.new_order;
|
|
1057
1425
|
const ask = input.ask.trim();
|
|
1058
1426
|
if (!confirm && ask === '')
|
|
1059
1427
|
throw new TypeError("Put the shopper's words in ask.");
|
|
1060
1428
|
// A pick chooses from the products or stores this conversation showed: never on a confirm, and never on a new order, which shows none yet.
|
|
1061
|
-
const picks = !confirm && !
|
|
1429
|
+
const picks = !confirm && !newOrder;
|
|
1062
1430
|
const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
|
|
1063
1431
|
const body = {
|
|
1064
1432
|
resume: true,
|
|
1065
|
-
...(confirm ? { confirm: true
|
|
1066
|
-
|
|
1433
|
+
...(confirm ? { confirm: true } : { ask }),
|
|
1434
|
+
// Every purchase through the tool pays from the shopper's Vault, so every call says so: an ask's carts are then
|
|
1435
|
+
// quoted with the fees the Vault payment charges, and the estimate the shopper sees is the one the confirm pays
|
|
1436
|
+
// (2026-10-21: a cart read $15.67 on its asks and $17.09 at its confirm).
|
|
1437
|
+
payment_source: 'vault',
|
|
1438
|
+
...(newOrder ? { new_order: true } : {}),
|
|
1067
1439
|
...(pick ? { pick } : {}),
|
|
1068
1440
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
1069
1441
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1070
1442
|
};
|
|
1443
|
+
// The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
|
|
1444
|
+
const yesHash = confirm ? lastShownHash : null;
|
|
1071
1445
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
1072
1446
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
1073
1447
|
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
@@ -1085,52 +1459,234 @@ export function buyTool(options) {
|
|
|
1085
1459
|
}
|
|
1086
1460
|
// The call keeps the turn it started in, whichever turn opens while it runs.
|
|
1087
1461
|
const t = explicit ?? implicit;
|
|
1462
|
+
// A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
|
|
1463
|
+
// shopper's own yes to this exact cart goes in, for that cart's hash.
|
|
1464
|
+
const plan = t.event ? await stepPlan(t.event) : null;
|
|
1465
|
+
const sent = plan && 'body' in plan ? plan.body : body;
|
|
1466
|
+
const confirmSent = plan ? 'body' in plan && sent.confirm !== undefined : confirm;
|
|
1088
1467
|
const callTurn = t.number;
|
|
1089
1468
|
const send = senderOf(t);
|
|
1090
1469
|
const progressOf = t.onProgress;
|
|
1091
|
-
//
|
|
1092
|
-
|
|
1093
|
-
|
|
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;
|
|
1474
|
+
const early = earlyCards && !!send && !confirmSent;
|
|
1094
1475
|
running += 1;
|
|
1095
1476
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1096
1477
|
// fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
|
|
1097
|
-
const ack = holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1478
|
+
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1098
1479
|
try {
|
|
1099
1480
|
let turn = null;
|
|
1100
1481
|
let refusal;
|
|
1101
|
-
|
|
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.
|
|
1102
1507
|
const handlers = streams
|
|
1103
1508
|
? {
|
|
1104
1509
|
...(progressOf ? { onProgress: (progress) => { try {
|
|
1105
1510
|
progressOf(progress);
|
|
1106
1511
|
}
|
|
1107
1512
|
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
1108
|
-
...(
|
|
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
|
|
1109
1532
|
? {
|
|
1110
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;
|
|
1111
1545
|
// Products arrived before the instant reply was due: they are the reply.
|
|
1112
1546
|
ack?.cancel();
|
|
1113
1547
|
await t.ackOut;
|
|
1114
1548
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
1115
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
|
+
}
|
|
1116
1590
|
},
|
|
1117
1591
|
}
|
|
1118
1592
|
: {}),
|
|
1119
1593
|
}
|
|
1120
1594
|
: undefined;
|
|
1121
|
-
|
|
1122
|
-
|
|
1595
|
+
if (plan && 'local' in plan) {
|
|
1596
|
+
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1123
1597
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1598
|
+
else {
|
|
1599
|
+
try {
|
|
1600
|
+
turn = await client.buy(sent, handlers);
|
|
1601
|
+
}
|
|
1602
|
+
catch (error) {
|
|
1603
|
+
refusal = error;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
// The store was still building the last request's cart: once it is in, the request that was refused goes as it was
|
|
1607
|
+
// sent, a step's own request too (a saved ask is no longer saved once its step sent it).
|
|
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;
|
|
1612
|
+
try {
|
|
1613
|
+
turn = await client.buy(sent, handlers);
|
|
1614
|
+
refusal = undefined;
|
|
1615
|
+
}
|
|
1616
|
+
catch (error) {
|
|
1617
|
+
refusal = error;
|
|
1618
|
+
}
|
|
1126
1619
|
}
|
|
1127
1620
|
ack?.cancel();
|
|
1128
|
-
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal,
|
|
1621
|
+
const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
|
|
1129
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)));
|
|
1130
1657
|
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1131
|
-
|
|
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
|
+
};
|
|
1132
1673
|
lastNextAction = result.next_action;
|
|
1133
1674
|
lastReply = result.reply;
|
|
1675
|
+
// A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
|
|
1676
|
+
if (turn) {
|
|
1677
|
+
lastCartCount = result.carts.length;
|
|
1678
|
+
lastCarts = result.carts;
|
|
1679
|
+
declinedNoCart = result.status === 'declined' && result.carts.length === 0;
|
|
1680
|
+
}
|
|
1681
|
+
if (result.conversation_id)
|
|
1682
|
+
lastConversationId = result.conversation_id;
|
|
1683
|
+
if (result.cart?.hash)
|
|
1684
|
+
lastShownHash = result.cart.hash;
|
|
1685
|
+
// 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
|
+
if (!t.event)
|
|
1687
|
+
noteShopperStep(result, turn, { confirm: !!confirm, yesHash, ask, withdrawalsAtStart });
|
|
1688
|
+
else if (plan && 'body' in plan)
|
|
1689
|
+
noteShopperStep(result, turn, { confirm: confirmSent, yesHash: typeof sent.confirm === 'string' ? sent.confirm : null, ask: sent.ask ?? '', withdrawalsAtStart });
|
|
1134
1690
|
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
1135
1691
|
const shown = shownProducts(result.products);
|
|
1136
1692
|
if (shown.length > 0)
|
|
@@ -1139,6 +1695,8 @@ export function buyTool(options) {
|
|
|
1139
1695
|
const storesShown = shownStores(result.stores);
|
|
1140
1696
|
if (storesShown.length > 0)
|
|
1141
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 }));
|
|
1142
1700
|
t.results?.push(result);
|
|
1143
1701
|
if (t.onResult) {
|
|
1144
1702
|
try {
|
|
@@ -1194,10 +1752,37 @@ export function buyTool(options) {
|
|
|
1194
1752
|
* with another message's, and it sends its instant reply at once, as `instantAck` would read it.
|
|
1195
1753
|
*/
|
|
1196
1754
|
const turn = (shopperText, options) => {
|
|
1755
|
+
withdrawUnlessGoAhead(String(shopperText ?? ''));
|
|
1756
|
+
return openTurn(String(shopperText ?? ''), options, null);
|
|
1757
|
+
};
|
|
1758
|
+
/**
|
|
1759
|
+
* The turn the agent takes on its own after a step the shopper finished outside the chat, or null
|
|
1760
|
+
* when there is none to take (`BuyTool.nextStep`). The message is answered by the model's words, so
|
|
1761
|
+
* the client never hands its text to a later turn as words the shopper read.
|
|
1762
|
+
*/
|
|
1763
|
+
const nextStep = (message, options) => {
|
|
1764
|
+
if (!message || typeof message !== 'object' || typeof message.kind !== 'string')
|
|
1765
|
+
throw new TypeError('Pass the message onMessage got.');
|
|
1766
|
+
if (message.advances !== true || isStale(message))
|
|
1767
|
+
return null;
|
|
1768
|
+
if (client.company && message.externalUserId !== externalUserId)
|
|
1769
|
+
return null;
|
|
1770
|
+
if (message.eventId && stepped.has(message.eventId))
|
|
1771
|
+
return null;
|
|
1772
|
+
// A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
|
|
1773
|
+
if (running > 0)
|
|
1774
|
+
return null;
|
|
1775
|
+
if (message.eventId)
|
|
1776
|
+
stepped.add(message.eventId);
|
|
1777
|
+
if (client.company)
|
|
1778
|
+
client.answered(externalUserId, message);
|
|
1779
|
+
return openTurn(nextStepPrompt(message), options, message);
|
|
1780
|
+
};
|
|
1781
|
+
const openTurn = (shopperText, options, event) => {
|
|
1197
1782
|
const own = options ?? {};
|
|
1198
1783
|
if (typeof own !== 'object')
|
|
1199
1784
|
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
1200
|
-
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem']) {
|
|
1785
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
1201
1786
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1202
1787
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1203
1788
|
}
|
|
@@ -1205,30 +1790,106 @@ export function buyTool(options) {
|
|
|
1205
1790
|
if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
|
|
1206
1791
|
throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
|
|
1207
1792
|
}
|
|
1793
|
+
if (onItem && own.onMessage !== undefined) {
|
|
1794
|
+
throw new TypeError('Pass onItem or onMessage to the turn, not both: onItem hears every message of the turn.');
|
|
1795
|
+
}
|
|
1796
|
+
const onMessage = onItem ? undefined : own.onMessage;
|
|
1797
|
+
if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
|
|
1798
|
+
throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
|
|
1799
|
+
const written = own.replyAsWritten ?? writtenByDefault;
|
|
1800
|
+
const hearText = own.onText ?? o.onText;
|
|
1208
1801
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1209
1802
|
const pauseOf = paceOf(own.pace);
|
|
1210
|
-
const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
|
|
1803
|
+
const feed = itemFeed(onItem || onMessage || hearText ? pauseOf : null, typingHook(own.onTyping));
|
|
1211
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.
|
|
1212
1805
|
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1213
1806
|
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
1214
1807
|
// `ack: false` sends no instant reply, whoever would hear it.
|
|
1215
1808
|
const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
|
|
1809
|
+
/**
|
|
1810
|
+
* Step links held for the message that asks for them. A turn whose model writes through it
|
|
1811
|
+
* (`onItem`, or `messageStream()`/`say()` once called) holds each step link a call hands over until
|
|
1812
|
+
* that message went out: the tool answers before the model writes, so the link used to reach the
|
|
1813
|
+
* chat above the words that explain it (2026-10-21). Product and store cards are what the message
|
|
1814
|
+
* is about, and go out at once. A held link goes out once the turn's next message ends, when the
|
|
1815
|
+
* next call starts, or after STEP_LINK_HOLD_MS, so no link is ever kept from the shopper.
|
|
1816
|
+
*/
|
|
1817
|
+
const held = [];
|
|
1818
|
+
// `onMessage` says the app writes the model through the turn, so its links wait for the message too (review of #9234).
|
|
1819
|
+
let writesThroughTurn = !!onItem || !!onMessage;
|
|
1820
|
+
let holdTimer = null;
|
|
1821
|
+
let linkNow = () => { };
|
|
1822
|
+
// A held link that failed to go out when no reply was ending (its timer ran out, or the next call started): the next
|
|
1823
|
+
// reply that ends rejects with it, so the app learns the shopper is missing a link (review of #9234).
|
|
1824
|
+
let lateFailure = null;
|
|
1825
|
+
/** Every held link joins the feed now, in order: settled once each went out, rejected with the first failure. */
|
|
1826
|
+
const release = () => {
|
|
1827
|
+
if (holdTimer)
|
|
1828
|
+
clearTimeout(holdTimer);
|
|
1829
|
+
holdTimer = null;
|
|
1830
|
+
const sends = held.splice(0).map((link) => {
|
|
1831
|
+
try {
|
|
1832
|
+
return Promise.resolve(linkNow(link));
|
|
1833
|
+
}
|
|
1834
|
+
catch (error) {
|
|
1835
|
+
return Promise.reject(error);
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
return Promise.all(sends).then(() => undefined);
|
|
1839
|
+
};
|
|
1840
|
+
const releaseLate = () => {
|
|
1841
|
+
release().catch((error) => {
|
|
1842
|
+
lateFailure ??= { error };
|
|
1843
|
+
});
|
|
1844
|
+
};
|
|
1845
|
+
/** The release a reply ends with: it waits for the held links, and fails with theirs or an earlier late failure. */
|
|
1846
|
+
const releaseForReply = async () => {
|
|
1847
|
+
await release();
|
|
1848
|
+
if (lateFailure) {
|
|
1849
|
+
const { error } = lateFailure;
|
|
1850
|
+
lateFailure = null;
|
|
1851
|
+
throw error;
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1216
1854
|
const state = {
|
|
1217
1855
|
...toolTurn(++modelTurn),
|
|
1856
|
+
event,
|
|
1218
1857
|
sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
|
|
1219
1858
|
// A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
|
|
1220
1859
|
...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
|
|
1221
1860
|
onResult: own.onResult ?? onResult,
|
|
1222
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,
|
|
1869
|
+
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1223
1870
|
links: [],
|
|
1224
1871
|
results: [],
|
|
1225
1872
|
};
|
|
1226
|
-
|
|
1227
|
-
|
|
1873
|
+
linkNow = state.sendLink;
|
|
1874
|
+
state.release = releaseLate;
|
|
1875
|
+
state.releaseNow = releaseForReply;
|
|
1876
|
+
state.sendLink = (link) => {
|
|
1877
|
+
if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
|
|
1878
|
+
return linkNow(link);
|
|
1879
|
+
held.push({ ...link });
|
|
1880
|
+
if (!holdTimer) {
|
|
1881
|
+
holdTimer = setTimeout(releaseLate, STEP_LINK_HOLD_MS);
|
|
1882
|
+
holdTimer.unref?.();
|
|
1883
|
+
}
|
|
1884
|
+
return Promise.resolve();
|
|
1885
|
+
};
|
|
1886
|
+
/** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
|
|
1887
|
+
const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), skip);
|
|
1228
1888
|
let ack = null;
|
|
1229
1889
|
const working = state.onWorking;
|
|
1230
1890
|
const reacting = state.onReaction;
|
|
1231
|
-
|
|
1891
|
+
// A turn the agent takes on its own answers no shopper message, so nothing gets a 👀 or an instant reply.
|
|
1892
|
+
if (!event && (working || reacting)) {
|
|
1232
1893
|
const message = String(shopperText ?? '');
|
|
1233
1894
|
const read = ackOfMessage(message);
|
|
1234
1895
|
let text = read.text;
|
|
@@ -1270,6 +1931,13 @@ export function buyTool(options) {
|
|
|
1270
1931
|
}
|
|
1271
1932
|
const links = state.links;
|
|
1272
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;
|
|
1273
1941
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
1274
1942
|
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
1275
1943
|
let handed = null;
|
|
@@ -1310,24 +1978,48 @@ export function buyTool(options) {
|
|
|
1310
1978
|
};
|
|
1311
1979
|
return toolOf((raw) => answerIn(raw, state), {}, {
|
|
1312
1980
|
agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
|
|
1981
|
+
event: { value: event, enumerable: false },
|
|
1982
|
+
release: {
|
|
1983
|
+
value: () => {
|
|
1984
|
+
if (!event)
|
|
1985
|
+
return;
|
|
1986
|
+
if (event.eventId)
|
|
1987
|
+
stepped.delete(event.eventId);
|
|
1988
|
+
if (client.company)
|
|
1989
|
+
client.unanswered(externalUserId, event);
|
|
1990
|
+
},
|
|
1991
|
+
enumerable: false,
|
|
1992
|
+
},
|
|
1313
1993
|
messages: { value: messagesFor, enumerable: false },
|
|
1314
1994
|
ack: { value: ack, enumerable: false },
|
|
1315
1995
|
reaction: { get: () => state.reaction, enumerable: false },
|
|
1316
1996
|
links: { get: () => [...links], enumerable: false },
|
|
1317
1997
|
results: { get: () => [...results], enumerable: false },
|
|
1998
|
+
direct: { value: direct, enumerable: false },
|
|
1999
|
+
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
1318
2000
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1319
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.
|
|
1320
|
-
|
|
2002
|
+
// A step link held for this message goes out right after its last paragraph, once the stream ends.
|
|
2003
|
+
messageStream: {
|
|
2004
|
+
value: () => {
|
|
2005
|
+
writesThroughTurn = true;
|
|
2006
|
+
return feedStream(text, releaseForReply);
|
|
2007
|
+
},
|
|
2008
|
+
enumerable: false,
|
|
2009
|
+
},
|
|
1321
2010
|
say: {
|
|
1322
2011
|
value: (whole) => {
|
|
2012
|
+
writesThroughTurn = true;
|
|
1323
2013
|
const reply = feedReply(text);
|
|
1324
2014
|
for (const message of toMessages(whole))
|
|
1325
2015
|
reply.add(message);
|
|
1326
|
-
|
|
2016
|
+
const released = releaseForReply();
|
|
2017
|
+
released.catch(() => { });
|
|
2018
|
+
return reply.settled().then(() => released);
|
|
1327
2019
|
},
|
|
1328
2020
|
enumerable: false,
|
|
1329
2021
|
},
|
|
1330
2022
|
});
|
|
1331
2023
|
};
|
|
1332
|
-
return toolOf(answer, { instantAck, instantReaction, turn }, {});
|
|
2024
|
+
return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
|
|
1333
2025
|
}
|