@agent-cards/commerce-sdk 0.11.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 +123 -6
- package/dist/ack.d.ts +12 -0
- package/dist/ack.js +29 -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 +119 -16
- package/dist/buy-tool.js +466 -33
- package/dist/client.d.ts +51 -1
- package/dist/client.js +79 -7
- package/dist/definition.js +24 -2
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -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 +49 -0
- package/dist/model-view.js +81 -4
- 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,6 +611,9 @@ 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.');
|
|
@@ -578,12 +663,14 @@ export function buyTool(options) {
|
|
|
578
663
|
sendLink,
|
|
579
664
|
onWorking,
|
|
580
665
|
onReaction,
|
|
666
|
+
tell: o.onWorking,
|
|
581
667
|
onResult,
|
|
582
668
|
onProgress,
|
|
583
669
|
links: null,
|
|
584
670
|
results: null,
|
|
585
671
|
pages: null,
|
|
586
672
|
storePages: null,
|
|
673
|
+
event: null,
|
|
587
674
|
});
|
|
588
675
|
/**
|
|
589
676
|
* The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
|
|
@@ -600,6 +687,31 @@ export function buyTool(options) {
|
|
|
600
687
|
let lastNextAction = null;
|
|
601
688
|
/** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
|
|
602
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;
|
|
603
715
|
/** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
|
|
604
716
|
let shownNames = [];
|
|
605
717
|
const namesShownProduct = (text) => {
|
|
@@ -623,8 +735,21 @@ export function buyTool(options) {
|
|
|
623
735
|
lastTemplate = nextTemplate(lastTemplate);
|
|
624
736
|
return { picked, text: said ? ackAt(lastTemplate, text) : null };
|
|
625
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
|
+
};
|
|
626
750
|
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
627
751
|
const instantAck = (shopperText) => {
|
|
752
|
+
withdrawUnlessGoAhead(shopperText);
|
|
628
753
|
const { picked, text } = ackOfMessage(shopperText);
|
|
629
754
|
appAck = { at: Date.now(), said: text !== null, picked };
|
|
630
755
|
return text;
|
|
@@ -714,6 +839,40 @@ export function buyTool(options) {
|
|
|
714
839
|
},
|
|
715
840
|
};
|
|
716
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
|
+
};
|
|
717
876
|
/** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
|
|
718
877
|
const read = async (conversationId) => {
|
|
719
878
|
try {
|
|
@@ -723,6 +882,17 @@ export function buyTool(options) {
|
|
|
723
882
|
return null;
|
|
724
883
|
}
|
|
725
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
|
+
};
|
|
726
896
|
/** The order this purchase placed, or the reason it stopped when the ledger says it did. */
|
|
727
897
|
const placed = (base, order, conversation) => {
|
|
728
898
|
const conversationId = conversation.conversation_id || base.conversation_id;
|
|
@@ -809,7 +979,7 @@ export function buyTool(options) {
|
|
|
809
979
|
...base,
|
|
810
980
|
status: 'approval_sent',
|
|
811
981
|
reply: APPROVAL_WAITING,
|
|
812
|
-
links: [
|
|
982
|
+
links: [approvalLink(last.approval_url, last.approval_expires_at, base.cart)],
|
|
813
983
|
approval_expires_at: last.approval_expires_at,
|
|
814
984
|
next_action: 'approve',
|
|
815
985
|
shopper_need_not_reply: announced,
|
|
@@ -975,7 +1145,7 @@ export function buyTool(options) {
|
|
|
975
1145
|
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
976
1146
|
const productId = pickId(product);
|
|
977
1147
|
// 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:
|
|
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 });
|
|
979
1149
|
}
|
|
980
1150
|
catch {
|
|
981
1151
|
continue;
|
|
@@ -1037,37 +1207,148 @@ export function buyTool(options) {
|
|
|
1037
1207
|
};
|
|
1038
1208
|
/** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
|
|
1039
1209
|
const deliver = async (result, call, send) => {
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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)
|
|
1043
1217
|
await send({ ...link });
|
|
1044
|
-
delivered = { ...
|
|
1218
|
+
delivered = { ...stamped, link_sent: true };
|
|
1045
1219
|
}
|
|
1046
1220
|
return deliverPagesInTurn(delivered, call, send);
|
|
1047
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
|
+
};
|
|
1048
1315
|
/**
|
|
1049
1316
|
* One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
|
|
1050
1317
|
* from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
|
|
1051
1318
|
*/
|
|
1052
1319
|
const answerIn = async (raw, explicit) => {
|
|
1053
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;
|
|
1054
1324
|
const input = parseBuyToolInput(raw);
|
|
1055
|
-
//
|
|
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).
|
|
1056
1331
|
const confirm = input.confirm && !input.new_order;
|
|
1057
1332
|
const ask = input.ask.trim();
|
|
1058
1333
|
if (!confirm && ask === '')
|
|
1059
1334
|
throw new TypeError("Put the shopper's words in ask.");
|
|
1060
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.
|
|
1061
|
-
const picks = !confirm && !
|
|
1336
|
+
const picks = !confirm && !newOrder;
|
|
1062
1337
|
const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
|
|
1063
1338
|
const body = {
|
|
1064
1339
|
resume: true,
|
|
1065
|
-
...(confirm ? { confirm: true
|
|
1066
|
-
|
|
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 } : {}),
|
|
1067
1346
|
...(pick ? { pick } : {}),
|
|
1068
1347
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
1069
1348
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1070
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;
|
|
1071
1352
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
1072
1353
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
1073
1354
|
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
@@ -1085,16 +1366,21 @@ export function buyTool(options) {
|
|
|
1085
1366
|
}
|
|
1086
1367
|
// The call keeps the turn it started in, whichever turn opens while it runs.
|
|
1087
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;
|
|
1088
1374
|
const callTurn = t.number;
|
|
1089
1375
|
const send = senderOf(t);
|
|
1090
1376
|
const progressOf = t.onProgress;
|
|
1091
1377
|
// A confirm streams as a search does, and only a search's products go out early.
|
|
1092
1378
|
const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
|
|
1093
|
-
const early = earlyCards && !!send && !
|
|
1379
|
+
const early = earlyCards && !!send && !confirmSent;
|
|
1094
1380
|
running += 1;
|
|
1095
1381
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1096
1382
|
// 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);
|
|
1383
|
+
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1098
1384
|
try {
|
|
1099
1385
|
let turn = null;
|
|
1100
1386
|
let refusal;
|
|
@@ -1118,19 +1404,49 @@ export function buyTool(options) {
|
|
|
1118
1404
|
: {}),
|
|
1119
1405
|
}
|
|
1120
1406
|
: undefined;
|
|
1121
|
-
|
|
1122
|
-
|
|
1407
|
+
if (plan && 'local' in plan) {
|
|
1408
|
+
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1123
1409
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1410
|
+
else {
|
|
1411
|
+
try {
|
|
1412
|
+
turn = await client.buy(sent, handlers);
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
refusal = error;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
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
|
+
}
|
|
1126
1428
|
}
|
|
1127
1429
|
ack?.cancel();
|
|
1128
|
-
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);
|
|
1129
1431
|
await t.ackOut;
|
|
1130
1432
|
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1131
1433
|
const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
1132
1434
|
lastNextAction = result.next_action;
|
|
1133
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 });
|
|
1134
1450
|
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
1135
1451
|
const shown = shownProducts(result.products);
|
|
1136
1452
|
if (shown.length > 0)
|
|
@@ -1194,10 +1510,37 @@ export function buyTool(options) {
|
|
|
1194
1510
|
* with another message's, and it sends its instant reply at once, as `instantAck` would read it.
|
|
1195
1511
|
*/
|
|
1196
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) => {
|
|
1197
1540
|
const own = options ?? {};
|
|
1198
1541
|
if (typeof own !== 'object')
|
|
1199
1542
|
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']) {
|
|
1543
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
|
|
1201
1544
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1202
1545
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1203
1546
|
}
|
|
@@ -1205,30 +1548,97 @@ export function buyTool(options) {
|
|
|
1205
1548
|
if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
|
|
1206
1549
|
throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
|
|
1207
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;
|
|
1208
1558
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1209
1559
|
const pauseOf = paceOf(own.pace);
|
|
1210
|
-
const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
|
|
1560
|
+
const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
|
|
1211
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.
|
|
1212
1562
|
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1213
1563
|
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
1214
1564
|
// `ack: false` sends no instant reply, whoever would hear it.
|
|
1215
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
|
+
};
|
|
1216
1611
|
const state = {
|
|
1217
1612
|
...toolTurn(++modelTurn),
|
|
1613
|
+
event,
|
|
1218
1614
|
sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
|
|
1219
1615
|
// A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
|
|
1220
1616
|
...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
|
|
1221
1617
|
onResult: own.onResult ?? onResult,
|
|
1222
1618
|
onProgress: own.onProgress ?? onProgress,
|
|
1619
|
+
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1223
1620
|
links: [],
|
|
1224
1621
|
results: [],
|
|
1225
1622
|
};
|
|
1226
|
-
|
|
1227
|
-
|
|
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);
|
|
1228
1637
|
let ack = null;
|
|
1229
1638
|
const working = state.onWorking;
|
|
1230
1639
|
const reacting = state.onReaction;
|
|
1231
|
-
|
|
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)) {
|
|
1232
1642
|
const message = String(shopperText ?? '');
|
|
1233
1643
|
const read = ackOfMessage(message);
|
|
1234
1644
|
let text = read.text;
|
|
@@ -1310,24 +1720,47 @@ export function buyTool(options) {
|
|
|
1310
1720
|
};
|
|
1311
1721
|
return toolOf((raw) => answerIn(raw, state), {}, {
|
|
1312
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
|
+
},
|
|
1313
1735
|
messages: { value: messagesFor, enumerable: false },
|
|
1314
1736
|
ack: { value: ack, enumerable: false },
|
|
1315
1737
|
reaction: { get: () => state.reaction, enumerable: false },
|
|
1316
1738
|
links: { get: () => [...links], enumerable: false },
|
|
1317
1739
|
results: { get: () => [...results], enumerable: false },
|
|
1740
|
+
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
1318
1741
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1319
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.
|
|
1320
|
-
|
|
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
|
+
},
|
|
1321
1751
|
say: {
|
|
1322
1752
|
value: (whole) => {
|
|
1753
|
+
writesThroughTurn = true;
|
|
1323
1754
|
const reply = feedReply(text);
|
|
1324
1755
|
for (const message of toMessages(whole))
|
|
1325
1756
|
reply.add(message);
|
|
1326
|
-
|
|
1757
|
+
const released = releaseForReply();
|
|
1758
|
+
released.catch(() => { });
|
|
1759
|
+
return reply.settled().then(() => released);
|
|
1327
1760
|
},
|
|
1328
1761
|
enumerable: false,
|
|
1329
1762
|
},
|
|
1330
1763
|
});
|
|
1331
1764
|
};
|
|
1332
|
-
return toolOf(answer, { instantAck, instantReaction, turn }, {});
|
|
1765
|
+
return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
|
|
1333
1766
|
}
|