@agent-cards/commerce-sdk 0.8.0 → 0.10.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 +117 -8
- package/dist/ack.d.ts +44 -2
- package/dist/ack.js +219 -10
- package/dist/adapters.d.ts +3 -3
- package/dist/adapters.js +2 -1
- package/dist/buy-tool.d.ts +190 -13
- package/dist/buy-tool.js +544 -91
- package/dist/client.d.ts +71 -3
- package/dist/client.js +79 -1
- package/dist/definition.d.ts +14 -6
- package/dist/definition.js +31 -12
- package/dist/handler.d.ts +6 -0
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/listen.d.ts +2 -0
- package/dist/listen.js +4 -1
- package/dist/messages.d.ts +33 -2
- package/dist/messages.js +82 -5
- package/dist/model-view.d.ts +48 -3
- package/dist/model-view.js +40 -3
- package/package.json +1 -1
package/dist/buy-tool.js
CHANGED
|
@@ -21,12 +21,70 @@
|
|
|
21
21
|
* still finishing gets one 12-second pause and one read of the conversation.
|
|
22
22
|
* A turn that outlived Agentcard's budget gets a read every 5 seconds for up
|
|
23
23
|
* to a minute, until the turn ends.
|
|
24
|
+
*
|
|
25
|
+
* `turn` binds the tool to one shopper message: the calls the model makes
|
|
26
|
+
* through it hand their links, answers and instant reply to that turn's own
|
|
27
|
+
* callbacks and lists, so a shopper who sends a second message before the
|
|
28
|
+
* first is answered never gets one message's cards beside the other's reply.
|
|
29
|
+
* Every turn of one tool still shares the shopper's conversation state, such
|
|
30
|
+
* as the store pages already sent. A turn with `onItem` hands the app its
|
|
31
|
+
* messages and links as one sequence, in the order they happened, and spaces
|
|
32
|
+
* the messages like someone texting.
|
|
24
33
|
*/
|
|
25
34
|
import { AgentcardApiError, AgentcardClient, } from './client.js';
|
|
26
35
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
27
|
-
import { forModel, forModelError, money, pickId, shownProducts } from './model-view.js';
|
|
28
|
-
import { toMessages } from './messages.js';
|
|
29
|
-
import { ackAt, instantAck as instantAckOf, isNewRequest, nextTemplate } from './ack.js';
|
|
36
|
+
import { forModel, forModelError, money, pickId, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
37
|
+
import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
|
|
38
|
+
import { ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
|
|
39
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
|
+
/**
|
|
41
|
+
* The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
|
|
42
|
+
* words. Agentcard's words join a last assistant message of plain text, a string or text parts only,
|
|
43
|
+
* so no provider reads two assistant messages in a row. After anything else, such as a tool result
|
|
44
|
+
* or an OpenAI Responses item, they come as an assistant message of their own.
|
|
45
|
+
*/
|
|
46
|
+
export function turnMessages(history, told, shopperText) {
|
|
47
|
+
if (history !== undefined && history !== null && !Array.isArray(history))
|
|
48
|
+
throw new TypeError("Pass the model's history as an array of messages, or leave it out.");
|
|
49
|
+
const out = [...(history ?? [])];
|
|
50
|
+
const said = told.map((text) => text.trim()).filter((text) => text !== '');
|
|
51
|
+
if (said.length > 0) {
|
|
52
|
+
const last = out.at(-1);
|
|
53
|
+
const content = isObject(last) && last.role === 'assistant' ? last.content : undefined;
|
|
54
|
+
if (typeof content === 'string') {
|
|
55
|
+
// The model's own words stay as it wrote them.
|
|
56
|
+
out[out.length - 1] = { ...last, content: content.trim() === '' ? said.join('\n\n') : [content, ...said].join('\n\n') };
|
|
57
|
+
}
|
|
58
|
+
else if (Array.isArray(content) && content.length > 0 && content.every((part) => isObject(part) && part.type === 'text' && typeof part.text === 'string')) {
|
|
59
|
+
out[out.length - 1] = { ...last, content: [...content, ...said.map((text) => ({ type: 'text', text }))] };
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
out.push({ role: 'assistant', content: said.join('\n\n') });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
out.push({ role: 'user', content: shopperText });
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
|
|
69
|
+
const modelText = (message) => stripLinks(String(message.text ?? ''));
|
|
70
|
+
/** How often the history held a message's text when the message first went in to the model. */
|
|
71
|
+
const offeredAt = new WeakMap();
|
|
72
|
+
/** How many times `needle` appears in `haystack`, none overlapping. */
|
|
73
|
+
function occurrences(haystack, needle) {
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (let at = haystack.indexOf(needle); needle !== '' && at !== -1; at = haystack.indexOf(needle, at + needle.length))
|
|
76
|
+
count++;
|
|
77
|
+
return count;
|
|
78
|
+
}
|
|
79
|
+
/** The history as JSON text, to find a message it already holds, or empty text for one JSON cannot write. */
|
|
80
|
+
function historyText(history) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.stringify(history ?? []) ?? '';
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
30
88
|
/** The pause before the one read that follows a placement Agentcard is still finishing. */
|
|
31
89
|
const PLACEMENT_WAIT_MS = 12_000;
|
|
32
90
|
/** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
|
|
@@ -46,6 +104,12 @@ const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 't
|
|
|
46
104
|
const ACK_DELAY_MS = 1_500;
|
|
47
105
|
/** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
|
|
48
106
|
const SAME_TURN_MS = 15_000;
|
|
107
|
+
/** A product name shorter than this, such as `AA`, is too short to tell a pick from a search. */
|
|
108
|
+
const MIN_NAME_LENGTH = 12;
|
|
109
|
+
/** Text as lowercase words with single spaces, so a product's name is found in a message that quotes it. */
|
|
110
|
+
function productWords(text) {
|
|
111
|
+
return String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
112
|
+
}
|
|
49
113
|
/** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
|
|
50
114
|
const APP_TURN_MS = 120_000;
|
|
51
115
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
@@ -59,6 +123,7 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
|
|
|
59
123
|
const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
|
|
60
124
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
61
125
|
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
|
+
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.";
|
|
62
127
|
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.";
|
|
63
128
|
/**
|
|
64
129
|
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
@@ -74,7 +139,8 @@ function pickAfter(pick, carts) {
|
|
|
74
139
|
function hashesOf(cart, carts) {
|
|
75
140
|
return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
|
|
76
141
|
}
|
|
77
|
-
|
|
142
|
+
/** Shared with the merchant monitor so link eligibility uses the tool's product mapping. */
|
|
143
|
+
export function productsOf(turn) {
|
|
78
144
|
return turn.catalog ? productsOfCatalog(turn.catalog) : [];
|
|
79
145
|
}
|
|
80
146
|
/** The products of a search, as the tool reads them. */
|
|
@@ -94,6 +160,10 @@ function productsOfCatalog(catalog) {
|
|
|
94
160
|
linkSent: false,
|
|
95
161
|
}));
|
|
96
162
|
}
|
|
163
|
+
/** The stores an answer presents, as the tool reads them. */
|
|
164
|
+
function storesOf(stores) {
|
|
165
|
+
return (stores?.items ?? []).map((store) => ({ id: store.id, name: store.name, url: store.store_page_url, etaMinutes: store.eta_minutes, imageUrl: store.image_url, linkSent: false }));
|
|
166
|
+
}
|
|
97
167
|
/** The store login link an answer carries, read from its own fields: a link in `reply` is never pulled out. */
|
|
98
168
|
function storeLoginOf(turn) {
|
|
99
169
|
if (!turn.store_login_url)
|
|
@@ -119,6 +189,8 @@ function answerOf(turn) {
|
|
|
119
189
|
error_code: turn.error_code,
|
|
120
190
|
next_action: turn.next_action,
|
|
121
191
|
pick: turn.pick,
|
|
192
|
+
stores: storesOf(turn.stores),
|
|
193
|
+
store_pick: turn.store_pick,
|
|
122
194
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
123
195
|
envelope: turn.raw,
|
|
124
196
|
};
|
|
@@ -217,6 +289,8 @@ function resultOfRefusal(error) {
|
|
|
217
289
|
error_code: error.code,
|
|
218
290
|
next_action: null,
|
|
219
291
|
pick: null,
|
|
292
|
+
stores: [],
|
|
293
|
+
store_pick: null,
|
|
220
294
|
hashes: hashesOf(null, carts),
|
|
221
295
|
envelope: error.body,
|
|
222
296
|
});
|
|
@@ -230,6 +304,8 @@ function resultOfRefusal(error) {
|
|
|
230
304
|
return answer(STILL_WORKING, []);
|
|
231
305
|
if (error.code === 'pick_not_found')
|
|
232
306
|
return answer(PICK_GONE, []);
|
|
307
|
+
if (error.code === 'store_pick_not_found')
|
|
308
|
+
return answer(STORE_PICK_GONE, []);
|
|
233
309
|
return null;
|
|
234
310
|
}
|
|
235
311
|
/** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
|
|
@@ -251,10 +327,126 @@ function needsCard(link, body) {
|
|
|
251
327
|
error_code: 'user_not_connected',
|
|
252
328
|
next_action: 'add_card',
|
|
253
329
|
pick: null,
|
|
330
|
+
stores: [],
|
|
331
|
+
store_pick: null,
|
|
254
332
|
hashes: [],
|
|
255
333
|
envelope: body,
|
|
256
334
|
};
|
|
257
335
|
}
|
|
336
|
+
/** The way a turn hands over a link, or undefined when it has nowhere to send one. */
|
|
337
|
+
function senderOf(t) {
|
|
338
|
+
if (!t.sendLink && !t.links)
|
|
339
|
+
return undefined;
|
|
340
|
+
return async (link) => {
|
|
341
|
+
if (t.sendLink)
|
|
342
|
+
await t.sendLink(link);
|
|
343
|
+
// A link sendLink threw on never reached the shopper, so the turn does not list it.
|
|
344
|
+
t.links?.push({ ...link });
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A turn's items, one at a time and in order: each one goes out once the one before it settled, and a
|
|
349
|
+
* `text` item first waits for its pause since the item before it (`pauseOf`). `items` lists each item
|
|
350
|
+
* that went out, in order. A failed item stays out of `items` and never stops the next one.
|
|
351
|
+
*/
|
|
352
|
+
function itemFeed(pauseOf, typing) {
|
|
353
|
+
const items = [];
|
|
354
|
+
// The item going out now, or null when none is: an item pushed while none is goes out at once.
|
|
355
|
+
let tail = null;
|
|
356
|
+
let lastAt = null;
|
|
357
|
+
const sent = (item) => {
|
|
358
|
+
items.push(item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item });
|
|
359
|
+
};
|
|
360
|
+
/** Sends one item now: settled when its send settled, and listed once it succeeded. */
|
|
361
|
+
const sendNow = (item, send) => {
|
|
362
|
+
let out;
|
|
363
|
+
try {
|
|
364
|
+
out = send();
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
lastAt = Date.now();
|
|
368
|
+
return Promise.reject(error);
|
|
369
|
+
}
|
|
370
|
+
if (out && typeof out.then === 'function') {
|
|
371
|
+
return Promise.resolve(out).then(() => {
|
|
372
|
+
lastAt = Date.now();
|
|
373
|
+
sent(item);
|
|
374
|
+
}, (error) => {
|
|
375
|
+
lastAt = Date.now();
|
|
376
|
+
throw error;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
lastAt = Date.now();
|
|
380
|
+
sent(item);
|
|
381
|
+
return Promise.resolve();
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* Adds an item to the feed. `skip` is read when the item's turn comes: true leaves it unsent and
|
|
385
|
+
* unlisted, with no pause, as for the rest of a reply whose earlier message failed.
|
|
386
|
+
*/
|
|
387
|
+
const push = (item, send, skip = () => false) => {
|
|
388
|
+
// Each message's pause is chosen once, when it joins the feed, so a pace that varies never makes it wait twice.
|
|
389
|
+
const pause = item.kind === 'text' && pauseOf ? pauseOf(item.text) : 0;
|
|
390
|
+
const waitFor = () => (pause > 0 && lastAt !== null ? lastAt + pause - Date.now() : 0);
|
|
391
|
+
let run;
|
|
392
|
+
if (tail === null && (skip() || waitFor() <= 0)) {
|
|
393
|
+
run = skip() ? Promise.resolve() : sendNow(item, send);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
run = (tail ?? Promise.resolve()).then(async () => {
|
|
397
|
+
if (skip())
|
|
398
|
+
return;
|
|
399
|
+
const wait = waitFor();
|
|
400
|
+
if (wait > 0) {
|
|
401
|
+
typing(true);
|
|
402
|
+
await sleep(wait);
|
|
403
|
+
typing(false);
|
|
404
|
+
}
|
|
405
|
+
await sendNow(item, send);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
const settled = run.catch(() => { }).then(() => {
|
|
409
|
+
if (tail === settled)
|
|
410
|
+
tail = null;
|
|
411
|
+
});
|
|
412
|
+
tail = settled;
|
|
413
|
+
return run;
|
|
414
|
+
};
|
|
415
|
+
return { items, push };
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* One reply's messages in a turn's feed: each one takes its place in the feed the moment it is cut,
|
|
419
|
+
* so no link overtakes it, and once one fails to send, none after it goes out, as `messageStream`
|
|
420
|
+
* stops after a failed send. `settled` resolves once every one went out or was left out, and rejects
|
|
421
|
+
* with the first failure.
|
|
422
|
+
*/
|
|
423
|
+
function feedReply(text) {
|
|
424
|
+
const sends = [];
|
|
425
|
+
let failure = null;
|
|
426
|
+
const add = (message) => {
|
|
427
|
+
sends.push(text(message, () => failure !== null).catch((error) => {
|
|
428
|
+
failure ??= { error };
|
|
429
|
+
}));
|
|
430
|
+
};
|
|
431
|
+
const settled = async () => {
|
|
432
|
+
await Promise.all(sends);
|
|
433
|
+
if (failure)
|
|
434
|
+
throw failure.error;
|
|
435
|
+
};
|
|
436
|
+
return { add, settled };
|
|
437
|
+
}
|
|
438
|
+
/** A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. */
|
|
439
|
+
function feedStream(text) {
|
|
440
|
+
const reply = feedReply(text);
|
|
441
|
+
const inner = messageStream({ onMessage: reply.add, pace: false });
|
|
442
|
+
return {
|
|
443
|
+
push: (delta) => inner.push(delta),
|
|
444
|
+
end: async () => {
|
|
445
|
+
await inner.end();
|
|
446
|
+
await reply.settled();
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
258
450
|
/**
|
|
259
451
|
* The order this purchase's checkout produced: the last checkout's own order, or the newest order
|
|
260
452
|
* placed since the last checkout, one that did not stop first. Agentcard clears the last checkout when
|
|
@@ -331,8 +523,8 @@ export function buyTool(options) {
|
|
|
331
523
|
const onProgress = o.onProgress;
|
|
332
524
|
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
333
525
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
334
|
-
|
|
335
|
-
const
|
|
526
|
+
// A call streams when it has someone to tell: a stage for onProgress, or early cards for the links of its turn.
|
|
527
|
+
const earlyCards = o.earlyCards === true;
|
|
336
528
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
337
529
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
338
530
|
}
|
|
@@ -350,17 +542,35 @@ export function buyTool(options) {
|
|
|
350
542
|
throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
|
|
351
543
|
const onWorking = o.ack === false ? undefined : o.onWorking;
|
|
352
544
|
const writeAck = typeof o.ack === 'function' ? o.ack : null;
|
|
353
|
-
/** Calls running now, when the last one ended,
|
|
545
|
+
/** Calls running now, when the last one ended, and the template the instant reply used last. */
|
|
354
546
|
let running = 0;
|
|
355
547
|
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
356
|
-
let turnAcked = false;
|
|
357
548
|
let lastTemplate = -1;
|
|
358
549
|
/** Counts the model's turns: the calls of one turn share its number. */
|
|
359
550
|
let modelTurn = 0;
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
551
|
+
/** A turn with the tool's own callbacks and nothing collected: the turn a call made straight on the tool belongs to. */
|
|
552
|
+
const toolTurn = (number) => ({
|
|
553
|
+
number,
|
|
554
|
+
acked: false,
|
|
555
|
+
neutral: false,
|
|
556
|
+
picked: null,
|
|
557
|
+
ackOut: null,
|
|
558
|
+
ackDelivered: false,
|
|
559
|
+
sendLink,
|
|
560
|
+
onWorking,
|
|
561
|
+
onResult,
|
|
562
|
+
onProgress,
|
|
563
|
+
links: null,
|
|
564
|
+
results: null,
|
|
565
|
+
pages: null,
|
|
566
|
+
storePages: null,
|
|
567
|
+
});
|
|
568
|
+
/**
|
|
569
|
+
* The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
|
|
570
|
+
* captures its turn when it starts, so a later message opening the next one never changes what a call
|
|
571
|
+
* still running for an earlier message waits for or tells the model.
|
|
572
|
+
*/
|
|
573
|
+
let implicit = toolTurn(0);
|
|
364
574
|
/**
|
|
365
575
|
* The app's instant reply for the shopper's newest message, waiting for the model's first call on that
|
|
366
576
|
* message, and the step the last answer asked the shopper for. A call still running for an earlier
|
|
@@ -368,29 +578,62 @@ export function buyTool(options) {
|
|
|
368
578
|
*/
|
|
369
579
|
let appAck = null;
|
|
370
580
|
let lastNextAction = null;
|
|
371
|
-
/** The
|
|
372
|
-
|
|
581
|
+
/** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
|
|
582
|
+
let lastReply = null;
|
|
583
|
+
/** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
|
|
584
|
+
let shownNames = [];
|
|
585
|
+
const namesShownProduct = (text) => {
|
|
586
|
+
const said = productWords(text);
|
|
587
|
+
return said !== '' && shownNames.some((name) => said.includes(name));
|
|
588
|
+
};
|
|
589
|
+
/** The names of the stores the last answer that listed stores showed: a message that names one, as a tap on its card does, picks it. */
|
|
590
|
+
let shownStoreNames = [];
|
|
591
|
+
/** True for a pick of a product or a store the shopper saw, by its name. */
|
|
592
|
+
const namesShown = (text) => namesShownProduct(text) || namesStore(text, shownStoreNames);
|
|
593
|
+
/** What a message picks: a product it names (`product`), a store or a place in a list (`pick`), or nothing (null). */
|
|
594
|
+
const pickOf = (text) => namesStore(text, shownStoreNames) ? 'pick' : namesShownProduct(text) ? 'product' : isPickText(text) ? 'pick' : null;
|
|
595
|
+
/** How the shopper's message reads for the instant reply: what it picks, and the reply it gets, or null. */
|
|
596
|
+
const ackOfMessage = (shopperText) => {
|
|
373
597
|
const text = String(shopperText ?? '');
|
|
374
|
-
|
|
598
|
+
// A shopper who sends a product's or a store's name, as a tap on its card does, picks it: no "looking for" reply.
|
|
599
|
+
const picked = pickOf(text);
|
|
600
|
+
const said = !picked && instantAckOf(text, { nextAction: lastNextAction, asked: lastReply }) !== null;
|
|
375
601
|
// The same rotation onWorking uses, so the next reply comes from another template.
|
|
376
602
|
if (said)
|
|
377
603
|
lastTemplate = nextTemplate(lastTemplate);
|
|
378
|
-
|
|
379
|
-
|
|
604
|
+
return { picked, text: said ? ackAt(lastTemplate, text) : null };
|
|
605
|
+
};
|
|
606
|
+
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
607
|
+
const instantAck = (shopperText) => {
|
|
608
|
+
const { picked, text } = ackOfMessage(shopperText);
|
|
609
|
+
appAck = { at: Date.now(), said: text !== null, picked };
|
|
610
|
+
return text;
|
|
380
611
|
};
|
|
381
612
|
/**
|
|
382
613
|
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
383
614
|
* not gone out, and frees the turn's reply for a later call.
|
|
384
615
|
*/
|
|
385
|
-
const holdAck = (ask) => {
|
|
386
|
-
|
|
616
|
+
const holdAck = (ask, t, step = null) => {
|
|
617
|
+
const onWorking = t.onWorking;
|
|
618
|
+
if (!onWorking || t.acked)
|
|
387
619
|
return null;
|
|
388
|
-
|
|
620
|
+
// A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
|
|
621
|
+
const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
|
|
622
|
+
// A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
|
|
623
|
+
// search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
|
|
624
|
+
const neutral = t.neutral || answersQuestion(ask, lastReply);
|
|
625
|
+
if (!slow && !neutral && !isNewRequest(ask))
|
|
626
|
+
return null;
|
|
627
|
+
t.acked = true;
|
|
389
628
|
let held = true;
|
|
390
629
|
const timer = setTimeout(() => {
|
|
391
630
|
held = false;
|
|
392
631
|
let text;
|
|
393
|
-
if (
|
|
632
|
+
if (slow) {
|
|
633
|
+
text = slowStepLine(slow);
|
|
634
|
+
}
|
|
635
|
+
else if (writeAck && !neutral) {
|
|
636
|
+
// A neutral turn's message held no request safe to read back, so no writer sees it.
|
|
394
637
|
try {
|
|
395
638
|
text = writeAck(ask);
|
|
396
639
|
}
|
|
@@ -400,16 +643,18 @@ export function buyTool(options) {
|
|
|
400
643
|
}
|
|
401
644
|
else {
|
|
402
645
|
lastTemplate = nextTemplate(lastTemplate);
|
|
403
|
-
|
|
646
|
+
// A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
|
|
647
|
+
// tool is working, and never that it is looking for anything.
|
|
648
|
+
text = neutral ? waitAt(lastTemplate) : ackAt(lastTemplate, ask);
|
|
404
649
|
}
|
|
405
650
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
406
|
-
|
|
651
|
+
t.acked = false;
|
|
407
652
|
return;
|
|
408
653
|
}
|
|
409
654
|
const message = text.trim();
|
|
410
|
-
ackOut = Promise.resolve()
|
|
655
|
+
t.ackOut = Promise.resolve()
|
|
411
656
|
.then(() => onWorking(message))
|
|
412
|
-
.then(() => { ackDelivered = true; }, () => { });
|
|
657
|
+
.then(() => { t.ackDelivered = true; }, () => { });
|
|
413
658
|
}, ackDelayMs);
|
|
414
659
|
return {
|
|
415
660
|
cancel: () => {
|
|
@@ -417,7 +662,7 @@ export function buyTool(options) {
|
|
|
417
662
|
return;
|
|
418
663
|
held = false;
|
|
419
664
|
clearTimeout(timer);
|
|
420
|
-
|
|
665
|
+
t.acked = false;
|
|
421
666
|
},
|
|
422
667
|
};
|
|
423
668
|
};
|
|
@@ -639,9 +884,10 @@ export function buyTool(options) {
|
|
|
639
884
|
/** The store pages this tool sent last, with their conversation, the model turn that sent them, and the search they list. */
|
|
640
885
|
let lastPages = null;
|
|
641
886
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
887
|
+
* A card for each product the model reads, with its store page, sent once
|
|
888
|
+
* per product: two dishes on a restaurant's one page are two cards. A card
|
|
889
|
+
* the last batch in this conversation already sent stays with the shopper
|
|
890
|
+
* and goes out no second time, unless a later model turn's reply
|
|
645
891
|
* presents the products again (the catalog's `presented`): a repeated ask
|
|
646
892
|
* answered with the same products gets their pages beside it, so every
|
|
647
893
|
* product the reply names has a card. Calls of one model turn still send a
|
|
@@ -650,62 +896,112 @@ export function buyTool(options) {
|
|
|
650
896
|
* that lists them again sends none twice. A page whose `sendLink` throws
|
|
651
897
|
* stays unsent, and the call goes on, so the next answer tries it again.
|
|
652
898
|
*/
|
|
653
|
-
const deliverPages = async (result, call) => {
|
|
654
|
-
if (!
|
|
899
|
+
const deliverPages = async (result, call, send) => {
|
|
900
|
+
if (!send)
|
|
655
901
|
return result;
|
|
656
|
-
|
|
902
|
+
// One card per product the model reads, so a restaurant's dishes, which all carry the store's one page, are a card each.
|
|
903
|
+
const cards = new Map();
|
|
657
904
|
for (const product of shownProducts(result.products)) {
|
|
658
|
-
|
|
659
|
-
|
|
905
|
+
const key = productCardKey(product);
|
|
906
|
+
if (key && !cards.has(key))
|
|
907
|
+
cards.set(key, product);
|
|
660
908
|
}
|
|
661
909
|
// An answer with no store page leaves the last batch as it was.
|
|
662
|
-
if (
|
|
910
|
+
if (cards.size === 0)
|
|
663
911
|
return result;
|
|
664
|
-
|
|
665
|
-
|
|
912
|
+
// An answer that presents its products reads the last batch of its own turn: another turn's batch
|
|
913
|
+
// sent in between, as when the shopper's next message answers first, never makes it send its cards again.
|
|
914
|
+
const last = call.presented && lastPages?.turn !== call.turn && call.owner.pages ? call.owner.pages : lastPages;
|
|
915
|
+
const kept = last?.conversationId === result.conversation_id && (!call.presented || last.turn === call.turn);
|
|
916
|
+
const before = kept && last ? last.keys : new Set();
|
|
666
917
|
// Agentcard names the search an answer lists by when it ran. An answer that lists fewer of the same
|
|
667
|
-
// search's products, such as a pick's, keeps every
|
|
918
|
+
// search's products, such as a pick's, keeps every card that search already sent, so a later answer
|
|
668
919
|
// that lists them again sends none twice.
|
|
669
920
|
const catalog = result.envelope?.catalog;
|
|
670
921
|
const search = typeof catalog?.as_of === 'string' ? catalog.as_of : null;
|
|
671
|
-
const sameSearch = kept && search !== null &&
|
|
922
|
+
const sameSearch = kept && search !== null && last?.search === search;
|
|
672
923
|
const held = new Set(sameSearch ? before : []);
|
|
673
|
-
for (const [
|
|
674
|
-
if (!before.has(
|
|
924
|
+
for (const [key, product] of cards) {
|
|
925
|
+
if (!before.has(key)) {
|
|
675
926
|
try {
|
|
676
927
|
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
677
928
|
const productId = pickId(product);
|
|
678
|
-
|
|
929
|
+
// 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: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
|
|
679
931
|
}
|
|
680
932
|
catch {
|
|
681
933
|
continue;
|
|
682
934
|
}
|
|
683
935
|
}
|
|
684
|
-
held.add(
|
|
936
|
+
held.add(key);
|
|
685
937
|
}
|
|
686
|
-
lastPages = { conversationId: result.conversation_id, turn: call.turn, search,
|
|
687
|
-
|
|
938
|
+
lastPages = { conversationId: result.conversation_id, turn: call.turn, search, keys: held };
|
|
939
|
+
call.owner.pages = lastPages;
|
|
940
|
+
return {
|
|
941
|
+
...result,
|
|
942
|
+
products: result.products.map((product) => {
|
|
943
|
+
const key = productCardKey(product);
|
|
944
|
+
return key && held.has(key) ? { ...product, linkSent: true } : product;
|
|
945
|
+
}),
|
|
946
|
+
};
|
|
688
947
|
};
|
|
689
948
|
/** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
|
|
690
949
|
const earlyPages = (conversationId, asOf, products) => ({ conversation_id: conversationId, envelope: { catalog: { as_of: asOf } }, products });
|
|
950
|
+
/** The store pages this tool sent last, with their conversation and the model turn that sent them. */
|
|
951
|
+
let lastStorePages = null;
|
|
952
|
+
/**
|
|
953
|
+
* The page of each store the reply presents, sent once per page in a model turn. Agentcard lists
|
|
954
|
+
* stores only on an answer whose reply presents them, so a later model turn that presents them
|
|
955
|
+
* again sends their pages again, and the calls of one model turn send each page once. A store
|
|
956
|
+
* whose id holds a link is left out, as the model view leaves it out. A page whose `sendLink`
|
|
957
|
+
* throws stays unsent, and the call goes on.
|
|
958
|
+
*/
|
|
959
|
+
const deliverStorePages = async (result, call, send) => {
|
|
960
|
+
if (!send || !result.stores?.length)
|
|
961
|
+
return result;
|
|
962
|
+
// A turn reads its own last batch when another turn's replaced it in between, so it never sends a store's page twice.
|
|
963
|
+
const last = lastStorePages?.turn !== call.turn && call.owner.storePages ? call.owner.storePages : lastStorePages;
|
|
964
|
+
const kept = last?.conversationId === result.conversation_id && last.turn === call.turn;
|
|
965
|
+
const held = new Set(kept && last ? last.urls : []);
|
|
966
|
+
for (const store of shownStores(result.stores)) {
|
|
967
|
+
if (!store.url)
|
|
968
|
+
continue;
|
|
969
|
+
if (!held.has(store.url)) {
|
|
970
|
+
try {
|
|
971
|
+
await send({ kind: 'store', url: store.url, title: store.name, storeId: store.id, etaMinutes: store.etaMinutes, imageUrl: store.imageUrl, expiresAt: null });
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
held.add(store.url);
|
|
978
|
+
}
|
|
979
|
+
lastStorePages = { conversationId: result.conversation_id, turn: call.turn, urls: held };
|
|
980
|
+
call.owner.storePages = lastStorePages;
|
|
981
|
+
return { ...result, stores: result.stores.map((store) => (store.url && held.has(store.url) ? { ...store, linkSent: true } : store)) };
|
|
982
|
+
};
|
|
691
983
|
/** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
|
|
692
984
|
let pagesInTurn = Promise.resolve();
|
|
693
|
-
const deliverPagesInTurn = (result, call) => {
|
|
694
|
-
const batch = pagesInTurn.then(() => deliverPages(result, call));
|
|
985
|
+
const deliverPagesInTurn = (result, call, send) => {
|
|
986
|
+
const batch = pagesInTurn.then(() => deliverPages(result, call, send)).then((delivered) => deliverStorePages(delivered, call, send));
|
|
695
987
|
pagesInTurn = batch.catch(() => { });
|
|
696
988
|
return batch;
|
|
697
989
|
};
|
|
698
|
-
/** The Vault or approval link first, because the purchase waits on it, then the store pages. */
|
|
699
|
-
const deliver = async (result, call) => {
|
|
990
|
+
/** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
|
|
991
|
+
const deliver = async (result, call, send) => {
|
|
700
992
|
let delivered = result;
|
|
701
|
-
if (result.links.length > 0 &&
|
|
993
|
+
if (result.links.length > 0 && send) {
|
|
702
994
|
for (const link of result.links)
|
|
703
|
-
await
|
|
995
|
+
await send({ ...link });
|
|
704
996
|
delivered = { ...result, link_sent: true };
|
|
705
997
|
}
|
|
706
|
-
return deliverPagesInTurn(delivered, call);
|
|
998
|
+
return deliverPagesInTurn(delivered, call, send);
|
|
707
999
|
};
|
|
708
|
-
|
|
1000
|
+
/**
|
|
1001
|
+
* One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
|
|
1002
|
+
* from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
|
|
1003
|
+
*/
|
|
1004
|
+
const answerIn = async (raw, explicit) => {
|
|
709
1005
|
const startedAt = Date.now();
|
|
710
1006
|
const input = parseBuyToolInput(raw);
|
|
711
1007
|
// A new order is never a confirm: a model that sets both drops the cart and places nothing.
|
|
@@ -713,8 +1009,9 @@ export function buyTool(options) {
|
|
|
713
1009
|
const ask = input.ask.trim();
|
|
714
1010
|
if (!confirm && ask === '')
|
|
715
1011
|
throw new TypeError("Put the shopper's words in ask.");
|
|
716
|
-
// A pick chooses from the products this conversation showed: never on a confirm, and never on a new order, which shows none yet.
|
|
717
|
-
const
|
|
1012
|
+
// 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 && !input.new_order;
|
|
1014
|
+
const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
|
|
718
1015
|
const body = {
|
|
719
1016
|
resume: true,
|
|
720
1017
|
...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
|
|
@@ -725,43 +1022,49 @@ export function buyTool(options) {
|
|
|
725
1022
|
};
|
|
726
1023
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
727
1024
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
728
|
-
// working through the same turn.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
ackDelivered = false;
|
|
741
|
-
modelTurn += 1;
|
|
1025
|
+
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
1026
|
+
if (!explicit) {
|
|
1027
|
+
const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
|
|
1028
|
+
appAck = null;
|
|
1029
|
+
if (opened) {
|
|
1030
|
+
// Only a reply the app sent quiets onWorking. A message such as `yes` that starts a slow search
|
|
1031
|
+
// still gets a reply that names nothing, and a slow pick gets one that says it is on it.
|
|
1032
|
+
implicit = { ...toolTurn(++modelTurn), acked: opened.said, neutral: !opened.said && !opened.picked, picked: opened.picked, ackDelivered: opened.said };
|
|
1033
|
+
}
|
|
1034
|
+
else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
|
|
1035
|
+
implicit = toolTurn(++modelTurn);
|
|
1036
|
+
}
|
|
742
1037
|
}
|
|
743
|
-
|
|
1038
|
+
// The call keeps the turn it started in, whichever turn opens while it runs.
|
|
1039
|
+
const t = explicit ?? implicit;
|
|
1040
|
+
const callTurn = t.number;
|
|
1041
|
+
const send = senderOf(t);
|
|
1042
|
+
const progressOf = t.onProgress;
|
|
1043
|
+
// A confirm streams as a search does, and only a search's products go out early.
|
|
1044
|
+
const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
|
|
1045
|
+
const early = earlyCards && !!send && !confirm;
|
|
744
1046
|
running += 1;
|
|
745
|
-
// A
|
|
746
|
-
|
|
1047
|
+
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1048
|
+
// 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);
|
|
747
1050
|
try {
|
|
748
1051
|
let turn = null;
|
|
749
1052
|
let refusal;
|
|
750
1053
|
// Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
|
|
751
1054
|
const handlers = streams
|
|
752
1055
|
? {
|
|
753
|
-
...(
|
|
754
|
-
|
|
1056
|
+
...(progressOf ? { onProgress: (progress) => { try {
|
|
1057
|
+
progressOf(progress);
|
|
755
1058
|
}
|
|
756
1059
|
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
757
|
-
...(
|
|
1060
|
+
...(early
|
|
758
1061
|
? {
|
|
759
1062
|
onCatalog: async (update) => {
|
|
760
1063
|
// Products arrived before the instant reply was due: they are the reply.
|
|
761
1064
|
ack?.cancel();
|
|
762
|
-
await ackOut;
|
|
1065
|
+
await t.ackOut;
|
|
763
1066
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
764
|
-
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true });
|
|
1067
|
+
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
|
|
765
1068
|
},
|
|
766
1069
|
}
|
|
767
1070
|
: {}),
|
|
@@ -775,13 +1078,23 @@ export function buyTool(options) {
|
|
|
775
1078
|
}
|
|
776
1079
|
ack?.cancel();
|
|
777
1080
|
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
|
|
778
|
-
await ackOut;
|
|
779
|
-
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true });
|
|
780
|
-
const result = ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
1081
|
+
await t.ackOut;
|
|
1082
|
+
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1083
|
+
const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
781
1084
|
lastNextAction = result.next_action;
|
|
782
|
-
|
|
1085
|
+
lastReply = result.reply;
|
|
1086
|
+
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
1087
|
+
const shown = shownProducts(result.products);
|
|
1088
|
+
if (shown.length > 0)
|
|
1089
|
+
shownNames = shown.map((product) => productWords(product.name)).filter((name) => name.length >= MIN_NAME_LENGTH);
|
|
1090
|
+
// An answer that lists stores replaces their names, as one that lists products replaces the products'.
|
|
1091
|
+
const storesShown = shownStores(result.stores);
|
|
1092
|
+
if (storesShown.length > 0)
|
|
1093
|
+
shownStoreNames = storesShown.map((store) => store.name);
|
|
1094
|
+
t.results?.push(result);
|
|
1095
|
+
if (t.onResult) {
|
|
783
1096
|
try {
|
|
784
|
-
await onResult(result);
|
|
1097
|
+
await t.onResult(result);
|
|
785
1098
|
}
|
|
786
1099
|
catch {
|
|
787
1100
|
// The app's display failing never fails the purchase call.
|
|
@@ -795,6 +1108,7 @@ export function buyTool(options) {
|
|
|
795
1108
|
lastEndedAt = Date.now();
|
|
796
1109
|
}
|
|
797
1110
|
};
|
|
1111
|
+
const answer = (raw) => answerIn(raw, null);
|
|
798
1112
|
/** Checks the model's input, with every link taken out of a refusal. */
|
|
799
1113
|
const parse = (input) => {
|
|
800
1114
|
try {
|
|
@@ -805,21 +1119,160 @@ export function buyTool(options) {
|
|
|
805
1119
|
}
|
|
806
1120
|
};
|
|
807
1121
|
/** Every call a framework makes: the model's input, checked, then the model's view of the answer as JSON text. */
|
|
808
|
-
const
|
|
1122
|
+
const handlerOf = (answerCall) => async (args) => {
|
|
809
1123
|
try {
|
|
810
|
-
return JSON.stringify(forModel(await
|
|
1124
|
+
return JSON.stringify(forModel(await answerCall(parseBuyToolInput(args))));
|
|
811
1125
|
}
|
|
812
1126
|
catch (error) {
|
|
813
1127
|
throw forModelError(error);
|
|
814
1128
|
}
|
|
815
1129
|
};
|
|
816
1130
|
const { name, description, input_schema } = buyToolDefinition();
|
|
817
|
-
|
|
818
|
-
// JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
|
|
1131
|
+
// JSON skips these, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
|
|
819
1132
|
const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1133
|
+
/** The fields every framework reads and the methods it calls, around one way to answer a call. */
|
|
1134
|
+
const toolOf = (answerCall, own, hidden) => {
|
|
1135
|
+
const handle = handlerOf(answerCall);
|
|
1136
|
+
const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer: answerCall, forModel, ...own };
|
|
1137
|
+
return Object.defineProperties(tool, {
|
|
1138
|
+
definition: unsent(buyToolDefinition()),
|
|
1139
|
+
inputSchema: unsent(buyToolStandardSchema((error) => forModelError(error).message)),
|
|
1140
|
+
openai: unsent(openAIBuyTool()),
|
|
1141
|
+
...hidden,
|
|
1142
|
+
});
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* The turn of one shopper message. It takes the next turn number, so its calls never share a turn
|
|
1146
|
+
* with another message's, and it sends its instant reply at once, as `instantAck` would read it.
|
|
1147
|
+
*/
|
|
1148
|
+
const turn = (shopperText, options) => {
|
|
1149
|
+
const own = options ?? {};
|
|
1150
|
+
if (typeof own !== 'object')
|
|
1151
|
+
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
1152
|
+
for (const key of ['sendLink', 'onWorking', 'onResult', 'onProgress', 'onItem']) {
|
|
1153
|
+
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1154
|
+
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1155
|
+
}
|
|
1156
|
+
const onItem = own.onItem;
|
|
1157
|
+
if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
|
|
1158
|
+
throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
|
|
1159
|
+
}
|
|
1160
|
+
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1161
|
+
const pauseOf = paceOf(own.pace);
|
|
1162
|
+
const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
|
|
1163
|
+
// 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
|
+
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1165
|
+
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
1166
|
+
// `ack: false` sends no instant reply, whoever would hear it.
|
|
1167
|
+
const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
|
|
1168
|
+
const state = {
|
|
1169
|
+
...toolTurn(++modelTurn),
|
|
1170
|
+
sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
|
|
1171
|
+
onWorking: acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1172
|
+
onResult: own.onResult ?? onResult,
|
|
1173
|
+
onProgress: own.onProgress ?? onProgress,
|
|
1174
|
+
links: [],
|
|
1175
|
+
results: [],
|
|
1176
|
+
};
|
|
1177
|
+
/** A message the app's model wrote: it goes to onItem, after every item before it, and the turn lists it. */
|
|
1178
|
+
const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => onItem?.({ kind: 'text', text: message }), skip);
|
|
1179
|
+
let ack = null;
|
|
1180
|
+
const working = state.onWorking;
|
|
1181
|
+
if (working) {
|
|
1182
|
+
const message = String(shopperText ?? '');
|
|
1183
|
+
const read = ackOfMessage(message);
|
|
1184
|
+
let text = read.text;
|
|
1185
|
+
if (text !== null && writeAck) {
|
|
1186
|
+
try {
|
|
1187
|
+
text = writeAck(message) ?? null;
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
text = null;
|
|
1191
|
+
}
|
|
1192
|
+
text = typeof text === 'string' && text.trim() !== '' ? text.trim() : null;
|
|
1193
|
+
}
|
|
1194
|
+
if (read.picked) {
|
|
1195
|
+
// A pick of a product or a store the shopper saw gets no "looking for" reply; a slow call says it is on it.
|
|
1196
|
+
state.picked = read.picked;
|
|
1197
|
+
}
|
|
1198
|
+
else if (read.text === null) {
|
|
1199
|
+
// A message that starts no search, such as `yes`: a slow call still tells the shopper, in words that name nothing.
|
|
1200
|
+
state.neutral = true;
|
|
1201
|
+
}
|
|
1202
|
+
else if (text !== null) {
|
|
1203
|
+
ack = text;
|
|
1204
|
+
state.acked = true;
|
|
1205
|
+
let sent;
|
|
1206
|
+
try {
|
|
1207
|
+
sent = working(text);
|
|
1208
|
+
}
|
|
1209
|
+
catch (error) {
|
|
1210
|
+
sent = Promise.reject(error);
|
|
1211
|
+
}
|
|
1212
|
+
// Every call of the turn waits for it before it hands over a link, and a failed send costs only the reply.
|
|
1213
|
+
state.ackOut = Promise.resolve(sent).then(() => { state.ackDelivered = true; }, () => { });
|
|
1214
|
+
}
|
|
1215
|
+
// An ack writer that wrote nothing for a new request leaves the slow call to ask it again, with the model's words.
|
|
1216
|
+
}
|
|
1217
|
+
const links = state.links;
|
|
1218
|
+
const results = state.results;
|
|
1219
|
+
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
1220
|
+
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
1221
|
+
let handed = null;
|
|
1222
|
+
const shopperWords = String(shopperText ?? '');
|
|
1223
|
+
/**
|
|
1224
|
+
* The model's messages for this turn. A kept message whose text the history holds more often than
|
|
1225
|
+
* when the message first went in reached the model on a turn whose history the app saved, so the
|
|
1226
|
+
* client forgets it. One the history lacks, as after a turn that failed before its history was
|
|
1227
|
+
* saved, goes in again.
|
|
1228
|
+
*/
|
|
1229
|
+
const messagesFor = (history) => {
|
|
1230
|
+
const held = historyText(history);
|
|
1231
|
+
const saved = [];
|
|
1232
|
+
const fresh = [];
|
|
1233
|
+
for (const message of kept) {
|
|
1234
|
+
const text = modelText(message);
|
|
1235
|
+
if (text === '') {
|
|
1236
|
+
saved.push(message);
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
// Another order's update can read the same, so only a history holding the text more often than
|
|
1240
|
+
// when this message first went in holds this message.
|
|
1241
|
+
const count = occurrences(held, JSON.stringify(text).slice(1, -1));
|
|
1242
|
+
const before = offeredAt.get(message);
|
|
1243
|
+
if (before !== undefined && count > before) {
|
|
1244
|
+
saved.push(message);
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
if (before === undefined)
|
|
1248
|
+
offeredAt.set(message, count);
|
|
1249
|
+
fresh.push(message);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (saved.length > 0)
|
|
1253
|
+
client.forgetMessages(externalUserId, saved);
|
|
1254
|
+
handed = fresh;
|
|
1255
|
+
return turnMessages(history, fresh.map(modelText), shopperWords);
|
|
1256
|
+
};
|
|
1257
|
+
return toolOf((raw) => answerIn(raw, state), {}, {
|
|
1258
|
+
agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
|
|
1259
|
+
messages: { value: messagesFor, enumerable: false },
|
|
1260
|
+
ack: { value: ack, enumerable: false },
|
|
1261
|
+
links: { get: () => [...links], enumerable: false },
|
|
1262
|
+
results: { get: () => [...results], enumerable: false },
|
|
1263
|
+
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1264
|
+
// 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
|
+
messageStream: { value: () => feedStream(text), enumerable: false },
|
|
1266
|
+
say: {
|
|
1267
|
+
value: (whole) => {
|
|
1268
|
+
const reply = feedReply(text);
|
|
1269
|
+
for (const message of toMessages(whole))
|
|
1270
|
+
reply.add(message);
|
|
1271
|
+
return reply.settled();
|
|
1272
|
+
},
|
|
1273
|
+
enumerable: false,
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
};
|
|
1277
|
+
return toolOf(answer, { instantAck, turn }, {});
|
|
825
1278
|
}
|