@agent-cards/commerce-sdk 0.10.0 → 0.13.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 +169 -17
- package/dist/ack.d.ts +29 -0
- package/dist/ack.js +48 -1
- 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 +168 -26
- package/dist/buy-tool.js +529 -41
- package/dist/client.d.ts +51 -1
- package/dist/client.js +79 -7
- package/dist/definition.js +31 -3
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/message.d.ts +10 -2
- package/dist/messages.d.ts +9 -9
- package/dist/messages.js +6 -6
- package/dist/model-view.d.ts +55 -0
- package/dist/model-view.js +92 -6
- package/dist/shopper-message.d.ts +15 -0
- package/dist/shopper-message.js +11 -0
- package/package.json +5 -1
package/dist/buy-tool.js
CHANGED
|
@@ -31,11 +31,19 @@
|
|
|
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 { forModel, forModelError,
|
|
36
|
+
import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
37
37
|
import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
|
|
38
|
-
import {
|
|
38
|
+
import { isStale } from './shopper-message.js';
|
|
39
|
+
import { actionLinkId, isActionLink } from './action-links.js';
|
|
40
|
+
import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
|
|
41
|
+
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
42
|
+
export function productAction(merchant) {
|
|
43
|
+
if (merchant === 'reservations')
|
|
44
|
+
return 'reserve';
|
|
45
|
+
return merchant === 'flights' || merchant === 'hotels' || merchant === 'tickets' || merchant === 'classes' ? 'book' : 'buy';
|
|
46
|
+
}
|
|
39
47
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
48
|
/**
|
|
41
49
|
* The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
|
|
@@ -112,7 +120,18 @@ function productWords(text) {
|
|
|
112
120
|
}
|
|
113
121
|
/** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
|
|
114
122
|
const APP_TURN_MS = 120_000;
|
|
123
|
+
/** The links that ask the shopper to take a step: they go out after the message that asks for it. */
|
|
124
|
+
const STEP_LINK_KINDS = new Set(['vault', 'approval', 'store_login']);
|
|
125
|
+
/** The longest a step link waits for its message: an app that never writes one still sends the link. */
|
|
126
|
+
const STEP_LINK_HOLD_MS = 20_000;
|
|
115
127
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
128
|
+
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.";
|
|
129
|
+
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.';
|
|
130
|
+
/** What the shopper hears while their message waits for the cart the store is still building. */
|
|
131
|
+
const stillBuildingLine = (store) => `still building your cart at ${store}, i'll get to this as soon as it's in`;
|
|
132
|
+
/** How often, and how many times, a message held for a cart being built reads whether the cart is in. */
|
|
133
|
+
const CART_HOLD_EVERY_MS = 2_000;
|
|
134
|
+
const CART_HOLD_READS = 45;
|
|
116
135
|
const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
|
|
117
136
|
const STILL_PLACING = 'The shopper approved, and the order is still going through.';
|
|
118
137
|
const NOT_PLACED = 'The order did not go through.';
|
|
@@ -125,6 +144,40 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
|
|
|
125
144
|
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
145
|
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
146
|
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.";
|
|
147
|
+
/** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
|
|
148
|
+
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.";
|
|
149
|
+
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.';
|
|
150
|
+
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.';
|
|
151
|
+
const NO_CART_WAITING = 'No cart is waiting. Carry on with what the shopper last asked for, in one short message.';
|
|
152
|
+
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.';
|
|
153
|
+
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.';
|
|
154
|
+
/** What the shopper just did outside the chat, for the line that opens a `nextStep` turn. */
|
|
155
|
+
function stepDone(message) {
|
|
156
|
+
const store = message.facts.merchantName;
|
|
157
|
+
switch (message.kind) {
|
|
158
|
+
case 'card_added':
|
|
159
|
+
return 'The shopper just saved a card outside the chat.';
|
|
160
|
+
case 'store_connected':
|
|
161
|
+
return `The shopper just signed in to ${store ?? 'the store'} outside the chat.`;
|
|
162
|
+
case 'approval_declined':
|
|
163
|
+
return `The shopper just declined the approval${store ? ` for the ${store} order` : ''} outside the chat. Nothing was charged and no order was placed.`;
|
|
164
|
+
default:
|
|
165
|
+
return 'The shopper just finished a step outside the chat.';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The words a `nextStep` turn hands the model as the user's message: the event, marked as no one's
|
|
170
|
+
* typing, and the instruction to take the next step now in one short message, without the filler of
|
|
171
|
+
* a step announced and a question about what comes next.
|
|
172
|
+
*/
|
|
173
|
+
export function nextStepPrompt(message) {
|
|
174
|
+
const note = String(message.note ?? '').trim();
|
|
175
|
+
return [
|
|
176
|
+
note || `[event: ${stepDone(message)} This note was generated by the system, not typed by the user.]`,
|
|
177
|
+
`${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.`,
|
|
178
|
+
"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.",
|
|
179
|
+
].join('\n\n');
|
|
180
|
+
}
|
|
128
181
|
/**
|
|
129
182
|
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
130
183
|
* the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
|
|
@@ -152,11 +205,14 @@ function productsOfCatalog(catalog) {
|
|
|
152
205
|
ref: item.ref,
|
|
153
206
|
name: item.name,
|
|
154
207
|
priceCents: item.priceCents,
|
|
208
|
+
...(item.price_from ? { priceFrom: true } : {}),
|
|
155
209
|
currency: item.currency,
|
|
156
210
|
imageUrl: item.image_url,
|
|
211
|
+
images: item.image_urls ?? (item.image_url ? [item.image_url] : []),
|
|
157
212
|
...(item.flight ? { flight: item.flight } : {}),
|
|
158
213
|
store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
|
|
159
214
|
url: item.url ?? null,
|
|
215
|
+
action: productAction(catalog.merchant),
|
|
160
216
|
linkSent: false,
|
|
161
217
|
}));
|
|
162
218
|
}
|
|
@@ -170,6 +226,14 @@ function storeLoginOf(turn) {
|
|
|
170
226
|
return [];
|
|
171
227
|
return [{ kind: 'store_login', url: turn.store_login_url, expiresAt: turn.store_login_expires_at, store: turn.store_login_merchant_name }];
|
|
172
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* The approval link for a cart, naming the cart's store in `store` when the answer names one, so a
|
|
231
|
+
* message about another store's cart never ends this approval (`actionLinkOutcome`).
|
|
232
|
+
*/
|
|
233
|
+
function approvalLink(url, expiresAt, cart) {
|
|
234
|
+
const store = cart?.merchant_name;
|
|
235
|
+
return { kind: 'approval', url, expiresAt, ...(store ? { store } : {}) };
|
|
236
|
+
}
|
|
173
237
|
/** The answer before its status is read: every field the purchase answer carries. */
|
|
174
238
|
function answerOf(turn) {
|
|
175
239
|
return {
|
|
@@ -192,6 +256,7 @@ function answerOf(turn) {
|
|
|
192
256
|
stores: storesOf(turn.stores),
|
|
193
257
|
store_pick: turn.store_pick,
|
|
194
258
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
259
|
+
cart_building: turn.cart_building,
|
|
195
260
|
envelope: turn.raw,
|
|
196
261
|
};
|
|
197
262
|
}
|
|
@@ -259,7 +324,7 @@ function resultOfStatus(turn) {
|
|
|
259
324
|
return {
|
|
260
325
|
...answer,
|
|
261
326
|
status: 'approval_sent',
|
|
262
|
-
links: [...answer.links, ...(turn.approval_url ? [
|
|
327
|
+
links: [...answer.links, ...(turn.approval_url ? [approvalLink(turn.approval_url, turn.approval_expires_at, turn.cart)] : [])],
|
|
263
328
|
shopper_need_not_reply: turn.placement_announced === true,
|
|
264
329
|
};
|
|
265
330
|
}
|
|
@@ -292,16 +357,19 @@ function resultOfRefusal(error) {
|
|
|
292
357
|
stores: [],
|
|
293
358
|
store_pick: null,
|
|
294
359
|
hashes: hashesOf(null, carts),
|
|
360
|
+
cart_building: cartBuildingOf(error.body.cart_building),
|
|
295
361
|
envelope: error.body,
|
|
296
362
|
});
|
|
297
363
|
if (error.code.startsWith('delivery_address'))
|
|
298
364
|
return answer(ADDRESS_CHANGED, []);
|
|
365
|
+
if (error.code === 'cart_not_ready')
|
|
366
|
+
return answer(CART_NOT_READY, []);
|
|
299
367
|
if (error.code === 'cart_changed')
|
|
300
368
|
return answer(CART_CHANGED, error.carts);
|
|
301
369
|
if (error.code === 'nothing_to_confirm' || error.code === 'conversation_closed')
|
|
302
370
|
return answer(NOTHING_TO_CONFIRM, []);
|
|
303
371
|
if (error.code === 'turn_in_progress')
|
|
304
|
-
return answer(STILL_WORKING, []);
|
|
372
|
+
return answer(cartBuildingOf(error.body.cart_building) ? STILL_BUILDING : STILL_WORKING, []);
|
|
305
373
|
if (error.code === 'pick_not_found')
|
|
306
374
|
return answer(PICK_GONE, []);
|
|
307
375
|
if (error.code === 'store_pick_not_found')
|
|
@@ -435,15 +503,29 @@ function feedReply(text) {
|
|
|
435
503
|
};
|
|
436
504
|
return { add, settled };
|
|
437
505
|
}
|
|
438
|
-
/**
|
|
439
|
-
|
|
506
|
+
/**
|
|
507
|
+
* A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. `ended` runs
|
|
508
|
+
* once the last message is cut and has its place in the feed, before it went out, so what it adds
|
|
509
|
+
* follows the reply. `end` settles once the reply and what `ended` added went out, and rejects with
|
|
510
|
+
* the first failure.
|
|
511
|
+
*/
|
|
512
|
+
function feedStream(text, ended) {
|
|
440
513
|
const reply = feedReply(text);
|
|
441
514
|
const inner = messageStream({ onMessage: reply.add, pace: false });
|
|
442
515
|
return {
|
|
443
516
|
push: (delta) => inner.push(delta),
|
|
444
517
|
end: async () => {
|
|
445
|
-
|
|
518
|
+
let added = Promise.resolve();
|
|
519
|
+
try {
|
|
520
|
+
await inner.end();
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
added = ended();
|
|
524
|
+
// Read below, after the reply: a failure here is never left unhandled while the reply settles.
|
|
525
|
+
added.catch(() => { });
|
|
526
|
+
}
|
|
446
527
|
await reply.settled();
|
|
528
|
+
await added;
|
|
447
529
|
},
|
|
448
530
|
};
|
|
449
531
|
}
|
|
@@ -529,19 +611,40 @@ export function buyTool(options) {
|
|
|
529
611
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
530
612
|
}
|
|
531
613
|
const onResult = o.onResult;
|
|
614
|
+
if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
|
|
615
|
+
throw new TypeError('Pass replyAsWritten as true or false.');
|
|
616
|
+
const writtenByDefault = o.replyAsWritten === true;
|
|
532
617
|
const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
533
618
|
if (typeof wait !== 'function')
|
|
534
619
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
535
620
|
const who = client.company ? { externalUserId } : {};
|
|
536
621
|
if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
|
|
537
622
|
throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
|
|
538
|
-
if (o.
|
|
539
|
-
throw new TypeError('Pass
|
|
623
|
+
if (o.onReaction !== undefined && typeof o.onReaction !== 'function')
|
|
624
|
+
throw new TypeError('Pass onReaction as a function that puts the emoji on the shopper\'s message.');
|
|
625
|
+
if (o.ack !== undefined && o.ack !== false && o.ack !== 'reaction' && o.ack !== 'text' && o.ack !== 'none' && typeof o.ack !== 'function') {
|
|
626
|
+
throw new TypeError("Pass ack as 'reaction', 'text' or 'none', as false, or as a function that writes the instant reply from the ask.");
|
|
627
|
+
}
|
|
540
628
|
const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
|
|
541
629
|
if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
|
|
542
630
|
throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
|
|
543
|
-
const onWorking = o.ack === false ? undefined : o.onWorking;
|
|
544
631
|
const writeAck = typeof o.ack === 'function' ? o.ack : null;
|
|
632
|
+
/** The mode `ack` names, or null when it names none and the callbacks a turn holds decide. */
|
|
633
|
+
const ackMode = o.ack === undefined ? null : o.ack === false || o.ack === 'none' ? 'none' : o.ack === 'reaction' ? 'reaction' : 'text';
|
|
634
|
+
/**
|
|
635
|
+
* Where a turn acknowledges, from the callbacks it holds: a reaction when it can react and `ack` asks
|
|
636
|
+
* for no text, else text. At most one of the two comes back, so a shopper never gets a 👀 and a line.
|
|
637
|
+
*/
|
|
638
|
+
const ackRoutes = (working, reacting) => {
|
|
639
|
+
if (ackMode === 'none')
|
|
640
|
+
return { onWorking: undefined, onReaction: undefined };
|
|
641
|
+
if (ackMode === 'text')
|
|
642
|
+
return { onWorking: working, onReaction: undefined };
|
|
643
|
+
if (ackMode === 'reaction' || reacting)
|
|
644
|
+
return { onWorking: undefined, onReaction: reacting };
|
|
645
|
+
return { onWorking: working, onReaction: undefined };
|
|
646
|
+
};
|
|
647
|
+
const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
|
|
545
648
|
/** Calls running now, when the last one ended, and the template the instant reply used last. */
|
|
546
649
|
let running = 0;
|
|
547
650
|
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
@@ -556,14 +659,18 @@ export function buyTool(options) {
|
|
|
556
659
|
picked: null,
|
|
557
660
|
ackOut: null,
|
|
558
661
|
ackDelivered: false,
|
|
662
|
+
reaction: null,
|
|
559
663
|
sendLink,
|
|
560
664
|
onWorking,
|
|
665
|
+
onReaction,
|
|
666
|
+
tell: o.onWorking,
|
|
561
667
|
onResult,
|
|
562
668
|
onProgress,
|
|
563
669
|
links: null,
|
|
564
670
|
results: null,
|
|
565
671
|
pages: null,
|
|
566
672
|
storePages: null,
|
|
673
|
+
event: null,
|
|
567
674
|
});
|
|
568
675
|
/**
|
|
569
676
|
* The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
|
|
@@ -580,6 +687,31 @@ export function buyTool(options) {
|
|
|
580
687
|
let lastNextAction = null;
|
|
581
688
|
/** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
|
|
582
689
|
let lastReply = null;
|
|
690
|
+
/** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
|
|
691
|
+
let lastCartCount = 0;
|
|
692
|
+
/** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
|
|
693
|
+
let lastConversationId = null;
|
|
694
|
+
let lastCarts = [];
|
|
695
|
+
/** The hash of the cart the last answer showed: the cart a confirm sent now confirms. */
|
|
696
|
+
let lastShownHash = null;
|
|
697
|
+
/**
|
|
698
|
+
* The yes the shopper gave in the chat to one cart, by its hash, when the confirm it sent stopped for a
|
|
699
|
+
* step outside the chat: a card to add or a store to sign in to. A `nextStep` turn confirms that cart
|
|
700
|
+
* once the step is done. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back.
|
|
701
|
+
*/
|
|
702
|
+
let carriedYes = null;
|
|
703
|
+
/** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
|
|
704
|
+
let cardAfterSignIn = null;
|
|
705
|
+
/** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
|
|
706
|
+
let unconnectedAsk = null;
|
|
707
|
+
/** The events this tool already took a `nextStep` turn for. */
|
|
708
|
+
const stepped = new Set();
|
|
709
|
+
/**
|
|
710
|
+
* How many shopper messages took back what the shopper asked for before them (any message that is not a plain go-ahead).
|
|
711
|
+
* A call that started before one of them leaves no yes and no words for a later step: its answer can land after the
|
|
712
|
+
* shopper said no, and a yes it carried would be confirmed by the next card or sign-in (review of #9220).
|
|
713
|
+
*/
|
|
714
|
+
let withdrawals = 0;
|
|
583
715
|
/** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
|
|
584
716
|
let shownNames = [];
|
|
585
717
|
const namesShownProduct = (text) => {
|
|
@@ -603,31 +735,72 @@ export function buyTool(options) {
|
|
|
603
735
|
lastTemplate = nextTemplate(lastTemplate);
|
|
604
736
|
return { picked, text: said ? ackAt(lastTemplate, text) : null };
|
|
605
737
|
};
|
|
738
|
+
/**
|
|
739
|
+
* A shopper message that is not a plain go-ahead takes back a yes the shopper gave before it, and words of theirs that
|
|
740
|
+
* never ran because they were not connected yet ("never mind" after them): a card saved later replays neither. Every
|
|
741
|
+
* way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
|
|
742
|
+
*/
|
|
743
|
+
const withdrawUnlessGoAhead = (shopperText) => {
|
|
744
|
+
if (keepsYes(String(shopperText ?? '')))
|
|
745
|
+
return;
|
|
746
|
+
carriedYes = null;
|
|
747
|
+
unconnectedAsk = null;
|
|
748
|
+
withdrawals++;
|
|
749
|
+
};
|
|
606
750
|
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
607
751
|
const instantAck = (shopperText) => {
|
|
752
|
+
withdrawUnlessGoAhead(shopperText);
|
|
608
753
|
const { picked, text } = ackOfMessage(shopperText);
|
|
609
754
|
appAck = { at: Date.now(), said: text !== null, picked };
|
|
610
755
|
return text;
|
|
611
756
|
};
|
|
757
|
+
/** The app's own reaction: the turn its message opens sends no reaction or reply of its own. */
|
|
758
|
+
const instantReaction = (shopperText) => (instantAck(shopperText) === null ? null : ACK_REACTION);
|
|
759
|
+
/**
|
|
760
|
+
* Puts the 👀 on the turn's message. Every call of the turn waits for it before it hands over a link,
|
|
761
|
+
* and one that throws costs only the reaction: `turn.reaction` goes back to null, and the model reads
|
|
762
|
+
* that the shopper was not told.
|
|
763
|
+
*/
|
|
764
|
+
const react = (t, reacting) => {
|
|
765
|
+
t.acked = true;
|
|
766
|
+
t.reaction = ACK_REACTION;
|
|
767
|
+
let sent;
|
|
768
|
+
try {
|
|
769
|
+
sent = reacting(ACK_REACTION);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
sent = Promise.reject(error);
|
|
773
|
+
}
|
|
774
|
+
t.ackOut = Promise.resolve(sent).then(() => { t.ackDelivered = true; }, () => { t.reaction = null; });
|
|
775
|
+
};
|
|
612
776
|
/**
|
|
613
777
|
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
614
778
|
* not gone out, and frees the turn's reply for a later call.
|
|
615
779
|
*/
|
|
616
780
|
const holdAck = (ask, t, step = null) => {
|
|
617
781
|
const onWorking = t.onWorking;
|
|
618
|
-
|
|
782
|
+
const reacting = t.onReaction;
|
|
783
|
+
if ((!onWorking && !reacting) || t.acked)
|
|
619
784
|
return null;
|
|
620
785
|
// A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
|
|
621
786
|
const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
|
|
622
787
|
// A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
|
|
623
788
|
// search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
|
|
624
789
|
const neutral = t.neutral || answersQuestion(ask, lastReply);
|
|
625
|
-
|
|
790
|
+
// A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
|
|
791
|
+
if (!reacting && !slow && !neutral && !isNewRequest(ask))
|
|
626
792
|
return null;
|
|
627
793
|
t.acked = true;
|
|
628
794
|
let held = true;
|
|
629
795
|
const timer = setTimeout(() => {
|
|
630
796
|
held = false;
|
|
797
|
+
// With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
|
|
798
|
+
if (reacting) {
|
|
799
|
+
react(t, reacting);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (!onWorking)
|
|
803
|
+
return;
|
|
631
804
|
let text;
|
|
632
805
|
if (slow) {
|
|
633
806
|
text = slowStepLine(slow);
|
|
@@ -666,6 +839,40 @@ export function buyTool(options) {
|
|
|
666
839
|
},
|
|
667
840
|
};
|
|
668
841
|
};
|
|
842
|
+
/**
|
|
843
|
+
* A message sent while the store builds the cart the last request asked for (`turn_in_progress` with `cart_building`) is
|
|
844
|
+
* never refused for it: the shopper hears the store is still building their cart, once, and the message waits until
|
|
845
|
+
* the cart is in, reading the conversation every two seconds for at most 90. True once the cart is in and the message
|
|
846
|
+
* can go; false for any other refusal, and when the wait gave up.
|
|
847
|
+
*/
|
|
848
|
+
const holdForCart = async (error, t, quiet) => {
|
|
849
|
+
if (!(error instanceof AgentcardApiError) || error.status !== 409 || error.code !== 'turn_in_progress' || !error.conversationId)
|
|
850
|
+
return false;
|
|
851
|
+
const building = cartBuildingOf(error.body.cart_building);
|
|
852
|
+
if (!building)
|
|
853
|
+
return false;
|
|
854
|
+
// The acknowledgement this message was due goes nowhere: the shopper hears the cart is still building instead, and no
|
|
855
|
+
// search runs meanwhile for it to speak of (Greptile on #9652).
|
|
856
|
+
quiet?.();
|
|
857
|
+
try {
|
|
858
|
+
await t.tell?.(stillBuildingLine(building.store));
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// The app's display failing never fails the purchase call.
|
|
862
|
+
}
|
|
863
|
+
// The 90 seconds hold however slow a read is: each read gets only the time left, so a read that hangs ends the wait.
|
|
864
|
+
const deadline = Date.now() + CART_HOLD_READS * CART_HOLD_EVERY_MS;
|
|
865
|
+
for (let i = 0; i < CART_HOLD_READS && Date.now() < deadline; i++) {
|
|
866
|
+
await wait(CART_HOLD_EVERY_MS);
|
|
867
|
+
const left = deadline - Date.now();
|
|
868
|
+
if (left <= 0)
|
|
869
|
+
break;
|
|
870
|
+
const conversation = await readWithin(error.conversationId, left);
|
|
871
|
+
if (conversation && !conversation.cart_building && !conversation.turn_in_progress)
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
return false;
|
|
875
|
+
};
|
|
669
876
|
/** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
|
|
670
877
|
const read = async (conversationId) => {
|
|
671
878
|
try {
|
|
@@ -675,6 +882,17 @@ export function buyTool(options) {
|
|
|
675
882
|
return null;
|
|
676
883
|
}
|
|
677
884
|
};
|
|
885
|
+
/** One read that gives up after `ms`, as a failed read does: the read itself is no longer waited for. */
|
|
886
|
+
const readWithin = async (conversationId, ms) => {
|
|
887
|
+
let timer;
|
|
888
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
|
|
889
|
+
try {
|
|
890
|
+
return await Promise.race([read(conversationId), late]);
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
clearTimeout(timer);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
678
896
|
/** The order this purchase placed, or the reason it stopped when the ledger says it did. */
|
|
679
897
|
const placed = (base, order, conversation) => {
|
|
680
898
|
const conversationId = conversation.conversation_id || base.conversation_id;
|
|
@@ -761,7 +979,7 @@ export function buyTool(options) {
|
|
|
761
979
|
...base,
|
|
762
980
|
status: 'approval_sent',
|
|
763
981
|
reply: APPROVAL_WAITING,
|
|
764
|
-
links: [
|
|
982
|
+
links: [approvalLink(last.approval_url, last.approval_expires_at, base.cart)],
|
|
765
983
|
approval_expires_at: last.approval_expires_at,
|
|
766
984
|
next_action: 'approve',
|
|
767
985
|
shopper_need_not_reply: announced,
|
|
@@ -927,7 +1145,7 @@ export function buyTool(options) {
|
|
|
927
1145
|
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
928
1146
|
const productId = pickId(product);
|
|
929
1147
|
// A flight option has no page at a store: its card carries no url, and its productId picks it.
|
|
930
|
-
await send({ kind: 'product', url: product.url || null, title: product.name, price:
|
|
1148
|
+
await send({ kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null });
|
|
931
1149
|
}
|
|
932
1150
|
catch {
|
|
933
1151
|
continue;
|
|
@@ -989,37 +1207,148 @@ export function buyTool(options) {
|
|
|
989
1207
|
};
|
|
990
1208
|
/** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
|
|
991
1209
|
const deliver = async (result, call, send) => {
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1210
|
+
// Each step link carries its stable id, in what the shopper is sent and in what the call returns.
|
|
1211
|
+
const stamped = result.links.some((link) => isActionLink(link) && !link.id)
|
|
1212
|
+
? { ...result, links: result.links.map((link) => (isActionLink(link) && !link.id ? { ...link, id: actionLinkId(link) } : link)) }
|
|
1213
|
+
: result;
|
|
1214
|
+
let delivered = stamped;
|
|
1215
|
+
if (stamped.links.length > 0 && send) {
|
|
1216
|
+
for (const link of stamped.links)
|
|
995
1217
|
await send({ ...link });
|
|
996
|
-
delivered = { ...
|
|
1218
|
+
delivered = { ...stamped, link_sent: true };
|
|
997
1219
|
}
|
|
998
1220
|
return deliverPagesInTurn(delivered, call, send);
|
|
999
1221
|
};
|
|
1222
|
+
/**
|
|
1223
|
+
* What a shopper's own call leaves for a later `nextStep` turn: the yes a confirm gave, while the
|
|
1224
|
+
* confirm stopped for a card or a store sign-in; the add-card link an answer held back while it asked
|
|
1225
|
+
* for the sign-in first; and the words of a shopper who was not connected yet.
|
|
1226
|
+
*/
|
|
1227
|
+
const noteShopperStep = (result, turn, call) => {
|
|
1228
|
+
// A shopper message that took things back while this call ran wins over what the call carried, and a newer call's
|
|
1229
|
+
// state stays as it is: an answer from before the withdrawal changes nothing here (review of #9220).
|
|
1230
|
+
if (call.withdrawalsAtStart !== withdrawals)
|
|
1231
|
+
return;
|
|
1232
|
+
const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
|
|
1233
|
+
if (call.confirm)
|
|
1234
|
+
carriedYes = outside && call.yesHash ? { hash: call.yesHash } : null;
|
|
1235
|
+
if (result.next_action === 'store_login' && turn?.add_card_url)
|
|
1236
|
+
cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
|
|
1237
|
+
else if (result.next_action !== 'store_login')
|
|
1238
|
+
cardAfterSignIn = null;
|
|
1239
|
+
if (!call.confirm && call.ask !== '')
|
|
1240
|
+
unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
|
|
1241
|
+
};
|
|
1242
|
+
/** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
|
|
1243
|
+
const stepResult = (fields) => ({
|
|
1244
|
+
messages: [],
|
|
1245
|
+
cart: null,
|
|
1246
|
+
carts: [],
|
|
1247
|
+
products: [],
|
|
1248
|
+
links: [],
|
|
1249
|
+
link_sent: false,
|
|
1250
|
+
approval_expires_at: null,
|
|
1251
|
+
shopper_need_not_reply: false,
|
|
1252
|
+
conversation_id: null,
|
|
1253
|
+
order_id: null,
|
|
1254
|
+
decline_code: null,
|
|
1255
|
+
error_code: null,
|
|
1256
|
+
next_action: null,
|
|
1257
|
+
pick: null,
|
|
1258
|
+
stores: [],
|
|
1259
|
+
store_pick: null,
|
|
1260
|
+
hashes: [],
|
|
1261
|
+
envelope: {},
|
|
1262
|
+
...fields,
|
|
1263
|
+
});
|
|
1264
|
+
/**
|
|
1265
|
+
* How a call of a `nextStep` turn goes. It never sends the model's ask, which Agentcard could read as
|
|
1266
|
+
* the shopper's go-ahead. It sends the shopper's own words that never ran because they were not
|
|
1267
|
+
* connected yet, once their card is saved, or a confirm of the one cart the shopper already said yes
|
|
1268
|
+
* to, by that cart's hash. Every other call answers from a read of the conversation: the card link the
|
|
1269
|
+
* sign-in held back, the cart and the ask for the shopper's yes, or the way forward after a decline.
|
|
1270
|
+
*/
|
|
1271
|
+
const stepPlan = async (event) => {
|
|
1272
|
+
const conversationId = event.conversationId ?? lastConversationId;
|
|
1273
|
+
const conversation = conversationId ? await read(conversationId) : null;
|
|
1274
|
+
const carts = conversation ? conversation.carts : lastCarts;
|
|
1275
|
+
const one = carts.length === 1 ? carts[0] : null;
|
|
1276
|
+
const who = {
|
|
1277
|
+
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
1278
|
+
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1279
|
+
};
|
|
1280
|
+
const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
|
|
1281
|
+
if (event.kind === 'approval_declined') {
|
|
1282
|
+
carriedYes = null;
|
|
1283
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
|
|
1284
|
+
}
|
|
1285
|
+
if (event.kind === 'card_added' && unconnectedAsk) {
|
|
1286
|
+
const ask = unconnectedAsk;
|
|
1287
|
+
unconnectedAsk = null;
|
|
1288
|
+
// Every purchase through the tool pays from the Vault, so a replayed ask names it too and its carts are quoted that way.
|
|
1289
|
+
return { body: { resume: true, ask, payment_source: 'vault', ...who } };
|
|
1290
|
+
}
|
|
1291
|
+
if (event.kind === 'store_connected' && cardAfterSignIn && !(Date.parse(cardAfterSignIn.expiresAt ?? '') <= Date.now())) {
|
|
1292
|
+
const link = cardAfterSignIn;
|
|
1293
|
+
cardAfterSignIn = null;
|
|
1294
|
+
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' }) };
|
|
1295
|
+
}
|
|
1296
|
+
if (carriedYes && one && conversationId && one.hash === carriedYes.hash) {
|
|
1297
|
+
carriedYes = null;
|
|
1298
|
+
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
|
|
1299
|
+
}
|
|
1300
|
+
// A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
|
|
1301
|
+
// it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
|
|
1302
|
+
const last = conversation?.last_checkout ?? null;
|
|
1303
|
+
const approvalOpen = !!last?.approval_url && !(Date.parse(last.approval_expires_at ?? '') <= Date.now());
|
|
1304
|
+
// A checkout whose order the store already took (`pending` while the charge settles, with its order id) is reported as
|
|
1305
|
+
// placed, with that order id, as the recovery path does, never as still going through (review of #9220).
|
|
1306
|
+
const order = conversation && last?.order_id ? placedOrderOf(conversation, 0) : null;
|
|
1307
|
+
if (conversation && order)
|
|
1308
|
+
return { local: placed(stepResult({ ...standing, status: 'needs_input', reply: '' }), order, conversation) };
|
|
1309
|
+
if (conversation && placementRunning(conversation))
|
|
1310
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_PLACEMENT_RUNNING }) };
|
|
1311
|
+
if (approvalOpen)
|
|
1312
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_APPROVAL_WAITING, next_action: 'approve' }) };
|
|
1313
|
+
return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? ASK_FOR_YES : NO_CART_WAITING, next_action: carts.length > 0 ? 'confirm' : null }) };
|
|
1314
|
+
};
|
|
1000
1315
|
/**
|
|
1001
1316
|
* One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
|
|
1002
1317
|
* from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
|
|
1003
1318
|
*/
|
|
1004
1319
|
const answerIn = async (raw, explicit) => {
|
|
1005
1320
|
const startedAt = Date.now();
|
|
1321
|
+
// A step link held for the message that introduces it goes out before this call hands over any of its own.
|
|
1322
|
+
explicit?.release?.();
|
|
1323
|
+
const withdrawalsAtStart = withdrawals;
|
|
1006
1324
|
const input = parseBuyToolInput(raw);
|
|
1007
|
-
//
|
|
1325
|
+
// With carts at more than one store waiting, a new order would drop every one of them: "cancel the
|
|
1326
|
+
// reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
|
|
1327
|
+
// The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
|
|
1328
|
+
const newOrder = input.new_order && lastCartCount < 2;
|
|
1329
|
+
// A new order is never a confirm: a model that sets both drops the cart and places nothing, even
|
|
1330
|
+
// when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
|
|
1008
1331
|
const confirm = input.confirm && !input.new_order;
|
|
1009
1332
|
const ask = input.ask.trim();
|
|
1010
1333
|
if (!confirm && ask === '')
|
|
1011
1334
|
throw new TypeError("Put the shopper's words in ask.");
|
|
1012
1335
|
// 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.
|
|
1013
|
-
const picks = !confirm && !
|
|
1336
|
+
const picks = !confirm && !newOrder;
|
|
1014
1337
|
const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
|
|
1015
1338
|
const body = {
|
|
1016
1339
|
resume: true,
|
|
1017
|
-
...(confirm ? { confirm: true
|
|
1018
|
-
|
|
1340
|
+
...(confirm ? { confirm: true } : { ask }),
|
|
1341
|
+
// Every purchase through the tool pays from the shopper's Vault, so every call says so: an ask's carts are then
|
|
1342
|
+
// quoted with the fees the Vault payment charges, and the estimate the shopper sees is the one the confirm pays
|
|
1343
|
+
// (2026-10-21: a cart read $15.67 on its asks and $17.09 at its confirm).
|
|
1344
|
+
payment_source: 'vault',
|
|
1345
|
+
...(newOrder ? { new_order: true } : {}),
|
|
1019
1346
|
...(pick ? { pick } : {}),
|
|
1020
1347
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
1021
1348
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1022
1349
|
};
|
|
1350
|
+
// The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
|
|
1351
|
+
const yesHash = confirm ? lastShownHash : null;
|
|
1023
1352
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
1024
1353
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
1025
1354
|
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
@@ -1037,16 +1366,21 @@ export function buyTool(options) {
|
|
|
1037
1366
|
}
|
|
1038
1367
|
// The call keeps the turn it started in, whichever turn opens while it runs.
|
|
1039
1368
|
const t = explicit ?? implicit;
|
|
1369
|
+
// A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
|
|
1370
|
+
// shopper's own yes to this exact cart goes in, for that cart's hash.
|
|
1371
|
+
const plan = t.event ? await stepPlan(t.event) : null;
|
|
1372
|
+
const sent = plan && 'body' in plan ? plan.body : body;
|
|
1373
|
+
const confirmSent = plan ? 'body' in plan && sent.confirm !== undefined : confirm;
|
|
1040
1374
|
const callTurn = t.number;
|
|
1041
1375
|
const send = senderOf(t);
|
|
1042
1376
|
const progressOf = t.onProgress;
|
|
1043
1377
|
// A confirm streams as a search does, and only a search's products go out early.
|
|
1044
1378
|
const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
|
|
1045
|
-
const early = earlyCards && !!send && !
|
|
1379
|
+
const early = earlyCards && !!send && !confirmSent;
|
|
1046
1380
|
running += 1;
|
|
1047
1381
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1048
1382
|
// fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
|
|
1049
|
-
const ack = holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1383
|
+
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1050
1384
|
try {
|
|
1051
1385
|
let turn = null;
|
|
1052
1386
|
let refusal;
|
|
@@ -1070,19 +1404,49 @@ export function buyTool(options) {
|
|
|
1070
1404
|
: {}),
|
|
1071
1405
|
}
|
|
1072
1406
|
: undefined;
|
|
1073
|
-
|
|
1074
|
-
|
|
1407
|
+
if (plan && 'local' in plan) {
|
|
1408
|
+
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
try {
|
|
1412
|
+
turn = await client.buy(sent, handlers);
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
refusal = error;
|
|
1416
|
+
}
|
|
1075
1417
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1418
|
+
// The store was still building the last request's cart: once it is in, the request that was refused goes as it was
|
|
1419
|
+
// sent, a step's own request too (a saved ask is no longer saved once its step sent it).
|
|
1420
|
+
if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
|
|
1421
|
+
try {
|
|
1422
|
+
turn = await client.buy(sent, handlers);
|
|
1423
|
+
refusal = undefined;
|
|
1424
|
+
}
|
|
1425
|
+
catch (error) {
|
|
1426
|
+
refusal = error;
|
|
1427
|
+
}
|
|
1078
1428
|
}
|
|
1079
1429
|
ack?.cancel();
|
|
1080
|
-
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal,
|
|
1430
|
+
const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
|
|
1081
1431
|
await t.ackOut;
|
|
1082
1432
|
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1083
1433
|
const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
1084
1434
|
lastNextAction = result.next_action;
|
|
1085
1435
|
lastReply = result.reply;
|
|
1436
|
+
// A refusal reports no carts, so only a turn's answer says how many wait.
|
|
1437
|
+
if (turn) {
|
|
1438
|
+
lastCartCount = result.carts.length;
|
|
1439
|
+
lastCarts = result.carts;
|
|
1440
|
+
}
|
|
1441
|
+
if (result.conversation_id)
|
|
1442
|
+
lastConversationId = result.conversation_id;
|
|
1443
|
+
if (result.cart?.hash)
|
|
1444
|
+
lastShownHash = result.cart.hash;
|
|
1445
|
+
// 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.
|
|
1446
|
+
if (!t.event)
|
|
1447
|
+
noteShopperStep(result, turn, { confirm: !!confirm, yesHash, ask, withdrawalsAtStart });
|
|
1448
|
+
else if (plan && 'body' in plan)
|
|
1449
|
+
noteShopperStep(result, turn, { confirm: confirmSent, yesHash: typeof sent.confirm === 'string' ? sent.confirm : null, ask: sent.ask ?? '', withdrawalsAtStart });
|
|
1086
1450
|
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
1087
1451
|
const shown = shownProducts(result.products);
|
|
1088
1452
|
if (shown.length > 0)
|
|
@@ -1146,10 +1510,37 @@ export function buyTool(options) {
|
|
|
1146
1510
|
* with another message's, and it sends its instant reply at once, as `instantAck` would read it.
|
|
1147
1511
|
*/
|
|
1148
1512
|
const turn = (shopperText, options) => {
|
|
1513
|
+
withdrawUnlessGoAhead(String(shopperText ?? ''));
|
|
1514
|
+
return openTurn(String(shopperText ?? ''), options, null);
|
|
1515
|
+
};
|
|
1516
|
+
/**
|
|
1517
|
+
* The turn the agent takes on its own after a step the shopper finished outside the chat, or null
|
|
1518
|
+
* when there is none to take (`BuyTool.nextStep`). The message is answered by the model's words, so
|
|
1519
|
+
* the client never hands its text to a later turn as words the shopper read.
|
|
1520
|
+
*/
|
|
1521
|
+
const nextStep = (message, options) => {
|
|
1522
|
+
if (!message || typeof message !== 'object' || typeof message.kind !== 'string')
|
|
1523
|
+
throw new TypeError('Pass the message onMessage got.');
|
|
1524
|
+
if (message.advances !== true || isStale(message))
|
|
1525
|
+
return null;
|
|
1526
|
+
if (client.company && message.externalUserId !== externalUserId)
|
|
1527
|
+
return null;
|
|
1528
|
+
if (message.eventId && stepped.has(message.eventId))
|
|
1529
|
+
return null;
|
|
1530
|
+
// A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
|
|
1531
|
+
if (running > 0)
|
|
1532
|
+
return null;
|
|
1533
|
+
if (message.eventId)
|
|
1534
|
+
stepped.add(message.eventId);
|
|
1535
|
+
if (client.company)
|
|
1536
|
+
client.answered(externalUserId, message);
|
|
1537
|
+
return openTurn(nextStepPrompt(message), options, message);
|
|
1538
|
+
};
|
|
1539
|
+
const openTurn = (shopperText, options, event) => {
|
|
1149
1540
|
const own = options ?? {};
|
|
1150
1541
|
if (typeof own !== 'object')
|
|
1151
1542
|
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
1152
|
-
for (const key of ['sendLink', 'onWorking', 'onResult', 'onProgress', 'onItem']) {
|
|
1543
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
|
|
1153
1544
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1154
1545
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1155
1546
|
}
|
|
@@ -1157,32 +1548,101 @@ export function buyTool(options) {
|
|
|
1157
1548
|
if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
|
|
1158
1549
|
throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
|
|
1159
1550
|
}
|
|
1551
|
+
if (onItem && own.onMessage !== undefined) {
|
|
1552
|
+
throw new TypeError('Pass onItem or onMessage to the turn, not both: onItem hears every message of the turn.');
|
|
1553
|
+
}
|
|
1554
|
+
const onMessage = onItem ? undefined : own.onMessage;
|
|
1555
|
+
if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
|
|
1556
|
+
throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
|
|
1557
|
+
const written = own.replyAsWritten ?? writtenByDefault;
|
|
1160
1558
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1161
1559
|
const pauseOf = paceOf(own.pace);
|
|
1162
|
-
const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
|
|
1560
|
+
const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
|
|
1163
1561
|
// With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
|
|
1164
1562
|
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1165
1563
|
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
1166
1564
|
// `ack: false` sends no instant reply, whoever would hear it.
|
|
1167
1565
|
const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
|
|
1566
|
+
/**
|
|
1567
|
+
* Step links held for the message that asks for them. A turn whose model writes through it
|
|
1568
|
+
* (`onItem`, or `messageStream()`/`say()` once called) holds each step link a call hands over until
|
|
1569
|
+
* that message went out: the tool answers before the model writes, so the link used to reach the
|
|
1570
|
+
* chat above the words that explain it (2026-10-21). Product and store cards are what the message
|
|
1571
|
+
* is about, and go out at once. A held link goes out once the turn's next message ends, when the
|
|
1572
|
+
* next call starts, or after STEP_LINK_HOLD_MS, so no link is ever kept from the shopper.
|
|
1573
|
+
*/
|
|
1574
|
+
const held = [];
|
|
1575
|
+
// `onMessage` says the app writes the model through the turn, so its links wait for the message too (review of #9234).
|
|
1576
|
+
let writesThroughTurn = !!onItem || !!onMessage;
|
|
1577
|
+
let holdTimer = null;
|
|
1578
|
+
let linkNow = () => { };
|
|
1579
|
+
// A held link that failed to go out when no reply was ending (its timer ran out, or the next call started): the next
|
|
1580
|
+
// reply that ends rejects with it, so the app learns the shopper is missing a link (review of #9234).
|
|
1581
|
+
let lateFailure = null;
|
|
1582
|
+
/** Every held link joins the feed now, in order: settled once each went out, rejected with the first failure. */
|
|
1583
|
+
const release = () => {
|
|
1584
|
+
if (holdTimer)
|
|
1585
|
+
clearTimeout(holdTimer);
|
|
1586
|
+
holdTimer = null;
|
|
1587
|
+
const sends = held.splice(0).map((link) => {
|
|
1588
|
+
try {
|
|
1589
|
+
return Promise.resolve(linkNow(link));
|
|
1590
|
+
}
|
|
1591
|
+
catch (error) {
|
|
1592
|
+
return Promise.reject(error);
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
return Promise.all(sends).then(() => undefined);
|
|
1596
|
+
};
|
|
1597
|
+
const releaseLate = () => {
|
|
1598
|
+
release().catch((error) => {
|
|
1599
|
+
lateFailure ??= { error };
|
|
1600
|
+
});
|
|
1601
|
+
};
|
|
1602
|
+
/** The release a reply ends with: it waits for the held links, and fails with theirs or an earlier late failure. */
|
|
1603
|
+
const releaseForReply = async () => {
|
|
1604
|
+
await release();
|
|
1605
|
+
if (lateFailure) {
|
|
1606
|
+
const { error } = lateFailure;
|
|
1607
|
+
lateFailure = null;
|
|
1608
|
+
throw error;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1168
1611
|
const state = {
|
|
1169
1612
|
...toolTurn(++modelTurn),
|
|
1613
|
+
event,
|
|
1170
1614
|
sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
|
|
1171
|
-
|
|
1615
|
+
// A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
|
|
1616
|
+
...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
|
|
1172
1617
|
onResult: own.onResult ?? onResult,
|
|
1173
1618
|
onProgress: own.onProgress ?? onProgress,
|
|
1619
|
+
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1174
1620
|
links: [],
|
|
1175
1621
|
results: [],
|
|
1176
1622
|
};
|
|
1177
|
-
|
|
1178
|
-
|
|
1623
|
+
linkNow = state.sendLink;
|
|
1624
|
+
state.release = releaseLate;
|
|
1625
|
+
state.sendLink = (link) => {
|
|
1626
|
+
if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
|
|
1627
|
+
return linkNow(link);
|
|
1628
|
+
held.push({ ...link });
|
|
1629
|
+
if (!holdTimer) {
|
|
1630
|
+
holdTimer = setTimeout(releaseLate, STEP_LINK_HOLD_MS);
|
|
1631
|
+
holdTimer.unref?.();
|
|
1632
|
+
}
|
|
1633
|
+
return Promise.resolve();
|
|
1634
|
+
};
|
|
1635
|
+
/** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
|
|
1636
|
+
const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), skip);
|
|
1179
1637
|
let ack = null;
|
|
1180
1638
|
const working = state.onWorking;
|
|
1181
|
-
|
|
1639
|
+
const reacting = state.onReaction;
|
|
1640
|
+
// A turn the agent takes on its own answers no shopper message, so nothing gets a 👀 or an instant reply.
|
|
1641
|
+
if (!event && (working || reacting)) {
|
|
1182
1642
|
const message = String(shopperText ?? '');
|
|
1183
1643
|
const read = ackOfMessage(message);
|
|
1184
1644
|
let text = read.text;
|
|
1185
|
-
if (text !== null && writeAck) {
|
|
1645
|
+
if (text !== null && writeAck && working) {
|
|
1186
1646
|
try {
|
|
1187
1647
|
text = writeAck(message) ?? null;
|
|
1188
1648
|
}
|
|
@@ -1199,7 +1659,11 @@ export function buyTool(options) {
|
|
|
1199
1659
|
// A message that starts no search, such as `yes`: a slow call still tells the shopper, in words that name nothing.
|
|
1200
1660
|
state.neutral = true;
|
|
1201
1661
|
}
|
|
1202
|
-
else if (
|
|
1662
|
+
else if (reacting) {
|
|
1663
|
+
// A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
|
|
1664
|
+
react(state, reacting);
|
|
1665
|
+
}
|
|
1666
|
+
else if (text !== null && working) {
|
|
1203
1667
|
ack = text;
|
|
1204
1668
|
state.acked = true;
|
|
1205
1669
|
let sent;
|
|
@@ -1256,23 +1720,47 @@ export function buyTool(options) {
|
|
|
1256
1720
|
};
|
|
1257
1721
|
return toolOf((raw) => answerIn(raw, state), {}, {
|
|
1258
1722
|
agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
|
|
1723
|
+
event: { value: event, enumerable: false },
|
|
1724
|
+
release: {
|
|
1725
|
+
value: () => {
|
|
1726
|
+
if (!event)
|
|
1727
|
+
return;
|
|
1728
|
+
if (event.eventId)
|
|
1729
|
+
stepped.delete(event.eventId);
|
|
1730
|
+
if (client.company)
|
|
1731
|
+
client.unanswered(externalUserId, event);
|
|
1732
|
+
},
|
|
1733
|
+
enumerable: false,
|
|
1734
|
+
},
|
|
1259
1735
|
messages: { value: messagesFor, enumerable: false },
|
|
1260
1736
|
ack: { value: ack, enumerable: false },
|
|
1737
|
+
reaction: { get: () => state.reaction, enumerable: false },
|
|
1261
1738
|
links: { get: () => [...links], enumerable: false },
|
|
1262
1739
|
results: { get: () => [...results], enumerable: false },
|
|
1740
|
+
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
1263
1741
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1264
1742
|
// 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.
|
|
1265
|
-
|
|
1743
|
+
// A step link held for this message goes out right after its last paragraph, once the stream ends.
|
|
1744
|
+
messageStream: {
|
|
1745
|
+
value: () => {
|
|
1746
|
+
writesThroughTurn = true;
|
|
1747
|
+
return feedStream(text, releaseForReply);
|
|
1748
|
+
},
|
|
1749
|
+
enumerable: false,
|
|
1750
|
+
},
|
|
1266
1751
|
say: {
|
|
1267
1752
|
value: (whole) => {
|
|
1753
|
+
writesThroughTurn = true;
|
|
1268
1754
|
const reply = feedReply(text);
|
|
1269
1755
|
for (const message of toMessages(whole))
|
|
1270
1756
|
reply.add(message);
|
|
1271
|
-
|
|
1757
|
+
const released = releaseForReply();
|
|
1758
|
+
released.catch(() => { });
|
|
1759
|
+
return reply.settled().then(() => released);
|
|
1272
1760
|
},
|
|
1273
1761
|
enumerable: false,
|
|
1274
1762
|
},
|
|
1275
1763
|
});
|
|
1276
1764
|
};
|
|
1277
|
-
return toolOf(answer, { instantAck, turn }, {});
|
|
1765
|
+
return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
|
|
1278
1766
|
}
|