@agent-cards/commerce-sdk 0.9.0 → 0.11.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 +142 -12
- package/dist/ack.d.ts +49 -2
- package/dist/ack.js +119 -6
- package/dist/adapters.d.ts +3 -3
- package/dist/adapters.js +1 -1
- package/dist/buy-tool.d.ts +201 -15
- package/dist/buy-tool.js +542 -108
- package/dist/client.d.ts +39 -3
- package/dist/client.js +66 -2
- package/dist/definition.js +16 -6
- package/dist/handler.d.ts +6 -0
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/listen.d.ts +2 -0
- package/dist/listen.js +4 -1
- package/dist/message.d.ts +7 -2
- package/dist/messages.d.ts +33 -2
- package/dist/messages.js +82 -5
- package/dist/model-view.d.ts +28 -1
- package/dist/model-view.js +36 -5
- 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, shownStores } from './model-view.js';
|
|
28
|
-
import { toMessages } from './messages.js';
|
|
29
|
-
import { ackAt, instantAck as instantAckOf, isNewRequest, isPickText, 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 { ACK_REACTION, 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. */
|
|
@@ -104,7 +162,7 @@ function productsOfCatalog(catalog) {
|
|
|
104
162
|
}
|
|
105
163
|
/** The stores an answer presents, as the tool reads them. */
|
|
106
164
|
function storesOf(stores) {
|
|
107
|
-
return (stores?.items ?? []).map((store) => ({ id: store.id, name: store.name, url: store.store_page_url, etaMinutes: store.eta_minutes, linkSent: false }));
|
|
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 }));
|
|
108
166
|
}
|
|
109
167
|
/** The store login link an answer carries, read from its own fields: a link in `reply` is never pulled out. */
|
|
110
168
|
function storeLoginOf(turn) {
|
|
@@ -275,6 +333,120 @@ function needsCard(link, body) {
|
|
|
275
333
|
envelope: body,
|
|
276
334
|
};
|
|
277
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
|
+
}
|
|
278
450
|
/**
|
|
279
451
|
* The order this purchase's checkout produced: the last checkout's own order, or the newest order
|
|
280
452
|
* placed since the last checkout, one that did not stop first. Agentcard clears the last checkout when
|
|
@@ -351,8 +523,8 @@ export function buyTool(options) {
|
|
|
351
523
|
const onProgress = o.onProgress;
|
|
352
524
|
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
353
525
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
354
|
-
|
|
355
|
-
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;
|
|
356
528
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
357
529
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
358
530
|
}
|
|
@@ -363,24 +535,62 @@ export function buyTool(options) {
|
|
|
363
535
|
const who = client.company ? { externalUserId } : {};
|
|
364
536
|
if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
|
|
365
537
|
throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
|
|
366
|
-
if (o.
|
|
367
|
-
throw new TypeError('Pass
|
|
538
|
+
if (o.onReaction !== undefined && typeof o.onReaction !== 'function')
|
|
539
|
+
throw new TypeError('Pass onReaction as a function that puts the emoji on the shopper\'s message.');
|
|
540
|
+
if (o.ack !== undefined && o.ack !== false && o.ack !== 'reaction' && o.ack !== 'text' && o.ack !== 'none' && typeof o.ack !== 'function') {
|
|
541
|
+
throw new TypeError("Pass ack as 'reaction', 'text' or 'none', as false, or as a function that writes the instant reply from the ask.");
|
|
542
|
+
}
|
|
368
543
|
const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
|
|
369
544
|
if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
|
|
370
545
|
throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
|
|
371
|
-
const onWorking = o.ack === false ? undefined : o.onWorking;
|
|
372
546
|
const writeAck = typeof o.ack === 'function' ? o.ack : null;
|
|
373
|
-
/**
|
|
547
|
+
/** The mode `ack` names, or null when it names none and the callbacks a turn holds decide. */
|
|
548
|
+
const ackMode = o.ack === undefined ? null : o.ack === false || o.ack === 'none' ? 'none' : o.ack === 'reaction' ? 'reaction' : 'text';
|
|
549
|
+
/**
|
|
550
|
+
* Where a turn acknowledges, from the callbacks it holds: a reaction when it can react and `ack` asks
|
|
551
|
+
* for no text, else text. At most one of the two comes back, so a shopper never gets a 👀 and a line.
|
|
552
|
+
*/
|
|
553
|
+
const ackRoutes = (working, reacting) => {
|
|
554
|
+
if (ackMode === 'none')
|
|
555
|
+
return { onWorking: undefined, onReaction: undefined };
|
|
556
|
+
if (ackMode === 'text')
|
|
557
|
+
return { onWorking: working, onReaction: undefined };
|
|
558
|
+
if (ackMode === 'reaction' || reacting)
|
|
559
|
+
return { onWorking: undefined, onReaction: reacting };
|
|
560
|
+
return { onWorking: working, onReaction: undefined };
|
|
561
|
+
};
|
|
562
|
+
const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
|
|
563
|
+
/** Calls running now, when the last one ended, and the template the instant reply used last. */
|
|
374
564
|
let running = 0;
|
|
375
565
|
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
376
|
-
let turnAcked = false;
|
|
377
566
|
let lastTemplate = -1;
|
|
378
567
|
/** Counts the model's turns: the calls of one turn share its number. */
|
|
379
568
|
let modelTurn = 0;
|
|
380
|
-
/**
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
569
|
+
/** A turn with the tool's own callbacks and nothing collected: the turn a call made straight on the tool belongs to. */
|
|
570
|
+
const toolTurn = (number) => ({
|
|
571
|
+
number,
|
|
572
|
+
acked: false,
|
|
573
|
+
neutral: false,
|
|
574
|
+
picked: null,
|
|
575
|
+
ackOut: null,
|
|
576
|
+
ackDelivered: false,
|
|
577
|
+
reaction: null,
|
|
578
|
+
sendLink,
|
|
579
|
+
onWorking,
|
|
580
|
+
onReaction,
|
|
581
|
+
onResult,
|
|
582
|
+
onProgress,
|
|
583
|
+
links: null,
|
|
584
|
+
results: null,
|
|
585
|
+
pages: null,
|
|
586
|
+
storePages: null,
|
|
587
|
+
});
|
|
588
|
+
/**
|
|
589
|
+
* The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
|
|
590
|
+
* captures its turn when it starts, so a later message opening the next one never changes what a call
|
|
591
|
+
* still running for an earlier message waits for or tells the model.
|
|
592
|
+
*/
|
|
593
|
+
let implicit = toolTurn(0);
|
|
384
594
|
/**
|
|
385
595
|
* The app's instant reply for the shopper's newest message, waiting for the model's first call on that
|
|
386
596
|
* message, and the step the last answer asked the shopper for. A call still running for an earlier
|
|
@@ -390,40 +600,88 @@ export function buyTool(options) {
|
|
|
390
600
|
let lastNextAction = null;
|
|
391
601
|
/** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
|
|
392
602
|
let lastReply = null;
|
|
393
|
-
/** True for a turn whose message `instantAck` sent no reply for and that picks nothing: a slow call still tells the shopper, in words that name nothing. */
|
|
394
|
-
let neutralTurn = false;
|
|
395
603
|
/** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
|
|
396
604
|
let shownNames = [];
|
|
397
605
|
const namesShownProduct = (text) => {
|
|
398
606
|
const said = productWords(text);
|
|
399
607
|
return said !== '' && shownNames.some((name) => said.includes(name));
|
|
400
608
|
};
|
|
401
|
-
/** The
|
|
402
|
-
|
|
609
|
+
/** The names of the stores the last answer that listed stores showed: a message that names one, as a tap on its card does, picks it. */
|
|
610
|
+
let shownStoreNames = [];
|
|
611
|
+
/** True for a pick of a product or a store the shopper saw, by its name. */
|
|
612
|
+
const namesShown = (text) => namesShownProduct(text) || namesStore(text, shownStoreNames);
|
|
613
|
+
/** What a message picks: a product it names (`product`), a store or a place in a list (`pick`), or nothing (null). */
|
|
614
|
+
const pickOf = (text) => namesStore(text, shownStoreNames) ? 'pick' : namesShownProduct(text) ? 'product' : isPickText(text) ? 'pick' : null;
|
|
615
|
+
/** How the shopper's message reads for the instant reply: what it picks, and the reply it gets, or null. */
|
|
616
|
+
const ackOfMessage = (shopperText) => {
|
|
403
617
|
const text = String(shopperText ?? '');
|
|
404
|
-
// A shopper who sends a product's name, as a tap on its card does, picks it: no "looking for" reply.
|
|
405
|
-
const
|
|
406
|
-
const said = !
|
|
618
|
+
// 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.
|
|
619
|
+
const picked = pickOf(text);
|
|
620
|
+
const said = !picked && instantAckOf(text, { nextAction: lastNextAction, asked: lastReply }) !== null;
|
|
407
621
|
// The same rotation onWorking uses, so the next reply comes from another template.
|
|
408
622
|
if (said)
|
|
409
623
|
lastTemplate = nextTemplate(lastTemplate);
|
|
410
|
-
|
|
411
|
-
|
|
624
|
+
return { picked, text: said ? ackAt(lastTemplate, text) : null };
|
|
625
|
+
};
|
|
626
|
+
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
627
|
+
const instantAck = (shopperText) => {
|
|
628
|
+
const { picked, text } = ackOfMessage(shopperText);
|
|
629
|
+
appAck = { at: Date.now(), said: text !== null, picked };
|
|
630
|
+
return text;
|
|
631
|
+
};
|
|
632
|
+
/** The app's own reaction: the turn its message opens sends no reaction or reply of its own. */
|
|
633
|
+
const instantReaction = (shopperText) => (instantAck(shopperText) === null ? null : ACK_REACTION);
|
|
634
|
+
/**
|
|
635
|
+
* Puts the 👀 on the turn's message. Every call of the turn waits for it before it hands over a link,
|
|
636
|
+
* and one that throws costs only the reaction: `turn.reaction` goes back to null, and the model reads
|
|
637
|
+
* that the shopper was not told.
|
|
638
|
+
*/
|
|
639
|
+
const react = (t, reacting) => {
|
|
640
|
+
t.acked = true;
|
|
641
|
+
t.reaction = ACK_REACTION;
|
|
642
|
+
let sent;
|
|
643
|
+
try {
|
|
644
|
+
sent = reacting(ACK_REACTION);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
sent = Promise.reject(error);
|
|
648
|
+
}
|
|
649
|
+
t.ackOut = Promise.resolve(sent).then(() => { t.ackDelivered = true; }, () => { t.reaction = null; });
|
|
412
650
|
};
|
|
413
651
|
/**
|
|
414
652
|
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
415
653
|
* not gone out, and frees the turn's reply for a later call.
|
|
416
654
|
*/
|
|
417
|
-
const holdAck = (ask,
|
|
418
|
-
|
|
655
|
+
const holdAck = (ask, t, step = null) => {
|
|
656
|
+
const onWorking = t.onWorking;
|
|
657
|
+
const reacting = t.onReaction;
|
|
658
|
+
if ((!onWorking && !reacting) || t.acked)
|
|
659
|
+
return null;
|
|
660
|
+
// A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
|
|
661
|
+
const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
|
|
662
|
+
// A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
|
|
663
|
+
// search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
|
|
664
|
+
const neutral = t.neutral || answersQuestion(ask, lastReply);
|
|
665
|
+
// A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
|
|
666
|
+
if (!reacting && !slow && !neutral && !isNewRequest(ask))
|
|
419
667
|
return null;
|
|
420
|
-
|
|
668
|
+
t.acked = true;
|
|
421
669
|
let held = true;
|
|
422
670
|
const timer = setTimeout(() => {
|
|
423
671
|
held = false;
|
|
672
|
+
// With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
|
|
673
|
+
if (reacting) {
|
|
674
|
+
react(t, reacting);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (!onWorking)
|
|
678
|
+
return;
|
|
424
679
|
let text;
|
|
425
|
-
|
|
426
|
-
|
|
680
|
+
if (slow) {
|
|
681
|
+
text = slowStepLine(slow);
|
|
682
|
+
}
|
|
683
|
+
else if (writeAck && !neutral) {
|
|
684
|
+
// A neutral turn's message held no request safe to read back, so no writer sees it.
|
|
427
685
|
try {
|
|
428
686
|
text = writeAck(ask);
|
|
429
687
|
}
|
|
@@ -433,17 +691,18 @@ export function buyTool(options) {
|
|
|
433
691
|
}
|
|
434
692
|
else {
|
|
435
693
|
lastTemplate = nextTemplate(lastTemplate);
|
|
436
|
-
// A neutral
|
|
437
|
-
|
|
694
|
+
// A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
|
|
695
|
+
// tool is working, and never that it is looking for anything.
|
|
696
|
+
text = neutral ? waitAt(lastTemplate) : ackAt(lastTemplate, ask);
|
|
438
697
|
}
|
|
439
698
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
440
|
-
|
|
699
|
+
t.acked = false;
|
|
441
700
|
return;
|
|
442
701
|
}
|
|
443
702
|
const message = text.trim();
|
|
444
|
-
ackOut = Promise.resolve()
|
|
703
|
+
t.ackOut = Promise.resolve()
|
|
445
704
|
.then(() => onWorking(message))
|
|
446
|
-
.then(() => { ackDelivered = true; }, () => { });
|
|
705
|
+
.then(() => { t.ackDelivered = true; }, () => { });
|
|
447
706
|
}, ackDelayMs);
|
|
448
707
|
return {
|
|
449
708
|
cancel: () => {
|
|
@@ -451,7 +710,7 @@ export function buyTool(options) {
|
|
|
451
710
|
return;
|
|
452
711
|
held = false;
|
|
453
712
|
clearTimeout(timer);
|
|
454
|
-
|
|
713
|
+
t.acked = false;
|
|
455
714
|
},
|
|
456
715
|
};
|
|
457
716
|
};
|
|
@@ -673,9 +932,10 @@ export function buyTool(options) {
|
|
|
673
932
|
/** The store pages this tool sent last, with their conversation, the model turn that sent them, and the search they list. */
|
|
674
933
|
let lastPages = null;
|
|
675
934
|
/**
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
935
|
+
* A card for each product the model reads, with its store page, sent once
|
|
936
|
+
* per product: two dishes on a restaurant's one page are two cards. A card
|
|
937
|
+
* the last batch in this conversation already sent stays with the shopper
|
|
938
|
+
* and goes out no second time, unless a later model turn's reply
|
|
679
939
|
* presents the products again (the catalog's `presented`): a repeated ask
|
|
680
940
|
* answered with the same products gets their pages beside it, so every
|
|
681
941
|
* product the reply names has a card. Calls of one model turn still send a
|
|
@@ -684,41 +944,54 @@ export function buyTool(options) {
|
|
|
684
944
|
* that lists them again sends none twice. A page whose `sendLink` throws
|
|
685
945
|
* stays unsent, and the call goes on, so the next answer tries it again.
|
|
686
946
|
*/
|
|
687
|
-
const deliverPages = async (result, call) => {
|
|
688
|
-
if (!
|
|
947
|
+
const deliverPages = async (result, call, send) => {
|
|
948
|
+
if (!send)
|
|
689
949
|
return result;
|
|
690
|
-
|
|
950
|
+
// One card per product the model reads, so a restaurant's dishes, which all carry the store's one page, are a card each.
|
|
951
|
+
const cards = new Map();
|
|
691
952
|
for (const product of shownProducts(result.products)) {
|
|
692
|
-
|
|
693
|
-
|
|
953
|
+
const key = productCardKey(product);
|
|
954
|
+
if (key && !cards.has(key))
|
|
955
|
+
cards.set(key, product);
|
|
694
956
|
}
|
|
695
957
|
// An answer with no store page leaves the last batch as it was.
|
|
696
|
-
if (
|
|
958
|
+
if (cards.size === 0)
|
|
697
959
|
return result;
|
|
698
|
-
|
|
699
|
-
|
|
960
|
+
// An answer that presents its products reads the last batch of its own turn: another turn's batch
|
|
961
|
+
// sent in between, as when the shopper's next message answers first, never makes it send its cards again.
|
|
962
|
+
const last = call.presented && lastPages?.turn !== call.turn && call.owner.pages ? call.owner.pages : lastPages;
|
|
963
|
+
const kept = last?.conversationId === result.conversation_id && (!call.presented || last.turn === call.turn);
|
|
964
|
+
const before = kept && last ? last.keys : new Set();
|
|
700
965
|
// Agentcard names the search an answer lists by when it ran. An answer that lists fewer of the same
|
|
701
|
-
// search's products, such as a pick's, keeps every
|
|
966
|
+
// search's products, such as a pick's, keeps every card that search already sent, so a later answer
|
|
702
967
|
// that lists them again sends none twice.
|
|
703
968
|
const catalog = result.envelope?.catalog;
|
|
704
969
|
const search = typeof catalog?.as_of === 'string' ? catalog.as_of : null;
|
|
705
|
-
const sameSearch = kept && search !== null &&
|
|
970
|
+
const sameSearch = kept && search !== null && last?.search === search;
|
|
706
971
|
const held = new Set(sameSearch ? before : []);
|
|
707
|
-
for (const [
|
|
708
|
-
if (!before.has(
|
|
972
|
+
for (const [key, product] of cards) {
|
|
973
|
+
if (!before.has(key)) {
|
|
709
974
|
try {
|
|
710
975
|
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
711
976
|
const productId = pickId(product);
|
|
712
|
-
|
|
977
|
+
// A flight option has no page at a store: its card carries no url, and its productId picks it.
|
|
978
|
+
await send({ kind: 'product', url: product.url || null, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
|
|
713
979
|
}
|
|
714
980
|
catch {
|
|
715
981
|
continue;
|
|
716
982
|
}
|
|
717
983
|
}
|
|
718
|
-
held.add(
|
|
984
|
+
held.add(key);
|
|
719
985
|
}
|
|
720
|
-
lastPages = { conversationId: result.conversation_id, turn: call.turn, search,
|
|
721
|
-
|
|
986
|
+
lastPages = { conversationId: result.conversation_id, turn: call.turn, search, keys: held };
|
|
987
|
+
call.owner.pages = lastPages;
|
|
988
|
+
return {
|
|
989
|
+
...result,
|
|
990
|
+
products: result.products.map((product) => {
|
|
991
|
+
const key = productCardKey(product);
|
|
992
|
+
return key && held.has(key) ? { ...product, linkSent: true } : product;
|
|
993
|
+
}),
|
|
994
|
+
};
|
|
722
995
|
};
|
|
723
996
|
/** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
|
|
724
997
|
const earlyPages = (conversationId, asOf, products) => ({ conversation_id: conversationId, envelope: { catalog: { as_of: asOf } }, products });
|
|
@@ -731,17 +1004,19 @@ export function buyTool(options) {
|
|
|
731
1004
|
* whose id holds a link is left out, as the model view leaves it out. A page whose `sendLink`
|
|
732
1005
|
* throws stays unsent, and the call goes on.
|
|
733
1006
|
*/
|
|
734
|
-
const deliverStorePages = async (result, call) => {
|
|
735
|
-
if (!
|
|
1007
|
+
const deliverStorePages = async (result, call, send) => {
|
|
1008
|
+
if (!send || !result.stores?.length)
|
|
736
1009
|
return result;
|
|
737
|
-
|
|
738
|
-
const
|
|
1010
|
+
// A turn reads its own last batch when another turn's replaced it in between, so it never sends a store's page twice.
|
|
1011
|
+
const last = lastStorePages?.turn !== call.turn && call.owner.storePages ? call.owner.storePages : lastStorePages;
|
|
1012
|
+
const kept = last?.conversationId === result.conversation_id && last.turn === call.turn;
|
|
1013
|
+
const held = new Set(kept && last ? last.urls : []);
|
|
739
1014
|
for (const store of shownStores(result.stores)) {
|
|
740
1015
|
if (!store.url)
|
|
741
1016
|
continue;
|
|
742
1017
|
if (!held.has(store.url)) {
|
|
743
1018
|
try {
|
|
744
|
-
await
|
|
1019
|
+
await send({ kind: 'store', url: store.url, title: store.name, storeId: store.id, etaMinutes: store.etaMinutes, imageUrl: store.imageUrl, expiresAt: null });
|
|
745
1020
|
}
|
|
746
1021
|
catch {
|
|
747
1022
|
continue;
|
|
@@ -750,26 +1025,31 @@ export function buyTool(options) {
|
|
|
750
1025
|
held.add(store.url);
|
|
751
1026
|
}
|
|
752
1027
|
lastStorePages = { conversationId: result.conversation_id, turn: call.turn, urls: held };
|
|
1028
|
+
call.owner.storePages = lastStorePages;
|
|
753
1029
|
return { ...result, stores: result.stores.map((store) => (store.url && held.has(store.url) ? { ...store, linkSent: true } : store)) };
|
|
754
1030
|
};
|
|
755
1031
|
/** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
|
|
756
1032
|
let pagesInTurn = Promise.resolve();
|
|
757
|
-
const deliverPagesInTurn = (result, call) => {
|
|
758
|
-
const batch = pagesInTurn.then(() => deliverPages(result, call)).then((delivered) => deliverStorePages(delivered, call));
|
|
1033
|
+
const deliverPagesInTurn = (result, call, send) => {
|
|
1034
|
+
const batch = pagesInTurn.then(() => deliverPages(result, call, send)).then((delivered) => deliverStorePages(delivered, call, send));
|
|
759
1035
|
pagesInTurn = batch.catch(() => { });
|
|
760
1036
|
return batch;
|
|
761
1037
|
};
|
|
762
|
-
/** The Vault or approval link first, because the purchase waits on it, then the store pages. */
|
|
763
|
-
const deliver = async (result, call) => {
|
|
1038
|
+
/** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
|
|
1039
|
+
const deliver = async (result, call, send) => {
|
|
764
1040
|
let delivered = result;
|
|
765
|
-
if (result.links.length > 0 &&
|
|
1041
|
+
if (result.links.length > 0 && send) {
|
|
766
1042
|
for (const link of result.links)
|
|
767
|
-
await
|
|
1043
|
+
await send({ ...link });
|
|
768
1044
|
delivered = { ...result, link_sent: true };
|
|
769
1045
|
}
|
|
770
|
-
return deliverPagesInTurn(delivered, call);
|
|
1046
|
+
return deliverPagesInTurn(delivered, call, send);
|
|
771
1047
|
};
|
|
772
|
-
|
|
1048
|
+
/**
|
|
1049
|
+
* One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
|
|
1050
|
+
* from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
|
|
1051
|
+
*/
|
|
1052
|
+
const answerIn = async (raw, explicit) => {
|
|
773
1053
|
const startedAt = Date.now();
|
|
774
1054
|
const input = parseBuyToolInput(raw);
|
|
775
1055
|
// A new order is never a confirm: a model that sets both drops the cart and places nothing.
|
|
@@ -790,47 +1070,49 @@ export function buyTool(options) {
|
|
|
790
1070
|
};
|
|
791
1071
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
792
1072
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
793
|
-
// working through the same turn.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const
|
|
1073
|
+
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
1074
|
+
if (!explicit) {
|
|
1075
|
+
const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
|
|
1076
|
+
appAck = null;
|
|
1077
|
+
if (opened) {
|
|
1078
|
+
// Only a reply the app sent quiets onWorking. A message such as `yes` that starts a slow search
|
|
1079
|
+
// still gets a reply that names nothing, and a slow pick gets one that says it is on it.
|
|
1080
|
+
implicit = { ...toolTurn(++modelTurn), acked: opened.said, neutral: !opened.said && !opened.picked, picked: opened.picked, ackDelivered: opened.said };
|
|
1081
|
+
}
|
|
1082
|
+
else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
|
|
1083
|
+
implicit = toolTurn(++modelTurn);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// The call keeps the turn it started in, whichever turn opens while it runs.
|
|
1087
|
+
const t = explicit ?? implicit;
|
|
1088
|
+
const callTurn = t.number;
|
|
1089
|
+
const send = senderOf(t);
|
|
1090
|
+
const progressOf = t.onProgress;
|
|
1091
|
+
// A confirm streams as a search does, and only a search's products go out early.
|
|
1092
|
+
const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
|
|
1093
|
+
const early = earlyCards && !!send && !confirm;
|
|
813
1094
|
running += 1;
|
|
814
|
-
// A
|
|
815
|
-
|
|
1095
|
+
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1096
|
+
// 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);
|
|
816
1098
|
try {
|
|
817
1099
|
let turn = null;
|
|
818
1100
|
let refusal;
|
|
819
1101
|
// Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
|
|
820
1102
|
const handlers = streams
|
|
821
1103
|
? {
|
|
822
|
-
...(
|
|
823
|
-
|
|
1104
|
+
...(progressOf ? { onProgress: (progress) => { try {
|
|
1105
|
+
progressOf(progress);
|
|
824
1106
|
}
|
|
825
1107
|
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
826
|
-
...(
|
|
1108
|
+
...(early
|
|
827
1109
|
? {
|
|
828
1110
|
onCatalog: async (update) => {
|
|
829
1111
|
// Products arrived before the instant reply was due: they are the reply.
|
|
830
1112
|
ack?.cancel();
|
|
831
|
-
await ackOut;
|
|
1113
|
+
await t.ackOut;
|
|
832
1114
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
833
|
-
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true });
|
|
1115
|
+
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
|
|
834
1116
|
},
|
|
835
1117
|
}
|
|
836
1118
|
: {}),
|
|
@@ -844,18 +1126,23 @@ export function buyTool(options) {
|
|
|
844
1126
|
}
|
|
845
1127
|
ack?.cancel();
|
|
846
1128
|
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
|
|
847
|
-
await ackOut;
|
|
848
|
-
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true });
|
|
849
|
-
const result = ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
1129
|
+
await t.ackOut;
|
|
1130
|
+
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1131
|
+
const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
850
1132
|
lastNextAction = result.next_action;
|
|
851
1133
|
lastReply = result.reply;
|
|
852
1134
|
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
853
1135
|
const shown = shownProducts(result.products);
|
|
854
1136
|
if (shown.length > 0)
|
|
855
1137
|
shownNames = shown.map((product) => productWords(product.name)).filter((name) => name.length >= MIN_NAME_LENGTH);
|
|
856
|
-
|
|
1138
|
+
// An answer that lists stores replaces their names, as one that lists products replaces the products'.
|
|
1139
|
+
const storesShown = shownStores(result.stores);
|
|
1140
|
+
if (storesShown.length > 0)
|
|
1141
|
+
shownStoreNames = storesShown.map((store) => store.name);
|
|
1142
|
+
t.results?.push(result);
|
|
1143
|
+
if (t.onResult) {
|
|
857
1144
|
try {
|
|
858
|
-
await onResult(result);
|
|
1145
|
+
await t.onResult(result);
|
|
859
1146
|
}
|
|
860
1147
|
catch {
|
|
861
1148
|
// The app's display failing never fails the purchase call.
|
|
@@ -869,6 +1156,7 @@ export function buyTool(options) {
|
|
|
869
1156
|
lastEndedAt = Date.now();
|
|
870
1157
|
}
|
|
871
1158
|
};
|
|
1159
|
+
const answer = (raw) => answerIn(raw, null);
|
|
872
1160
|
/** Checks the model's input, with every link taken out of a refusal. */
|
|
873
1161
|
const parse = (input) => {
|
|
874
1162
|
try {
|
|
@@ -879,21 +1167,167 @@ export function buyTool(options) {
|
|
|
879
1167
|
}
|
|
880
1168
|
};
|
|
881
1169
|
/** Every call a framework makes: the model's input, checked, then the model's view of the answer as JSON text. */
|
|
882
|
-
const
|
|
1170
|
+
const handlerOf = (answerCall) => async (args) => {
|
|
883
1171
|
try {
|
|
884
|
-
return JSON.stringify(forModel(await
|
|
1172
|
+
return JSON.stringify(forModel(await answerCall(parseBuyToolInput(args))));
|
|
885
1173
|
}
|
|
886
1174
|
catch (error) {
|
|
887
1175
|
throw forModelError(error);
|
|
888
1176
|
}
|
|
889
1177
|
};
|
|
890
1178
|
const { name, description, input_schema } = buyToolDefinition();
|
|
891
|
-
|
|
892
|
-
// JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
|
|
1179
|
+
// JSON skips these, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
|
|
893
1180
|
const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1181
|
+
/** The fields every framework reads and the methods it calls, around one way to answer a call. */
|
|
1182
|
+
const toolOf = (answerCall, own, hidden) => {
|
|
1183
|
+
const handle = handlerOf(answerCall);
|
|
1184
|
+
const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer: answerCall, forModel, ...own };
|
|
1185
|
+
return Object.defineProperties(tool, {
|
|
1186
|
+
definition: unsent(buyToolDefinition()),
|
|
1187
|
+
inputSchema: unsent(buyToolStandardSchema((error) => forModelError(error).message)),
|
|
1188
|
+
openai: unsent(openAIBuyTool()),
|
|
1189
|
+
...hidden,
|
|
1190
|
+
});
|
|
1191
|
+
};
|
|
1192
|
+
/**
|
|
1193
|
+
* The turn of one shopper message. It takes the next turn number, so its calls never share a turn
|
|
1194
|
+
* with another message's, and it sends its instant reply at once, as `instantAck` would read it.
|
|
1195
|
+
*/
|
|
1196
|
+
const turn = (shopperText, options) => {
|
|
1197
|
+
const own = options ?? {};
|
|
1198
|
+
if (typeof own !== 'object')
|
|
1199
|
+
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']) {
|
|
1201
|
+
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
1202
|
+
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
1203
|
+
}
|
|
1204
|
+
const onItem = own.onItem;
|
|
1205
|
+
if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
|
|
1206
|
+
throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
|
|
1207
|
+
}
|
|
1208
|
+
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1209
|
+
const pauseOf = paceOf(own.pace);
|
|
1210
|
+
const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
|
|
1211
|
+
// 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
|
+
const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
|
|
1213
|
+
const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
|
|
1214
|
+
// `ack: false` sends no instant reply, whoever would hear it.
|
|
1215
|
+
const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
|
|
1216
|
+
const state = {
|
|
1217
|
+
...toolTurn(++modelTurn),
|
|
1218
|
+
sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
|
|
1219
|
+
// A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
|
|
1220
|
+
...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
|
|
1221
|
+
onResult: own.onResult ?? onResult,
|
|
1222
|
+
onProgress: own.onProgress ?? onProgress,
|
|
1223
|
+
links: [],
|
|
1224
|
+
results: [],
|
|
1225
|
+
};
|
|
1226
|
+
/** A message the app's model wrote: it goes to onItem, after every item before it, and the turn lists it. */
|
|
1227
|
+
const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => onItem?.({ kind: 'text', text: message }), skip);
|
|
1228
|
+
let ack = null;
|
|
1229
|
+
const working = state.onWorking;
|
|
1230
|
+
const reacting = state.onReaction;
|
|
1231
|
+
if (working || reacting) {
|
|
1232
|
+
const message = String(shopperText ?? '');
|
|
1233
|
+
const read = ackOfMessage(message);
|
|
1234
|
+
let text = read.text;
|
|
1235
|
+
if (text !== null && writeAck && working) {
|
|
1236
|
+
try {
|
|
1237
|
+
text = writeAck(message) ?? null;
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
text = null;
|
|
1241
|
+
}
|
|
1242
|
+
text = typeof text === 'string' && text.trim() !== '' ? text.trim() : null;
|
|
1243
|
+
}
|
|
1244
|
+
if (read.picked) {
|
|
1245
|
+
// A pick of a product or a store the shopper saw gets no "looking for" reply; a slow call says it is on it.
|
|
1246
|
+
state.picked = read.picked;
|
|
1247
|
+
}
|
|
1248
|
+
else if (read.text === null) {
|
|
1249
|
+
// A message that starts no search, such as `yes`: a slow call still tells the shopper, in words that name nothing.
|
|
1250
|
+
state.neutral = true;
|
|
1251
|
+
}
|
|
1252
|
+
else if (reacting) {
|
|
1253
|
+
// A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
|
|
1254
|
+
react(state, reacting);
|
|
1255
|
+
}
|
|
1256
|
+
else if (text !== null && working) {
|
|
1257
|
+
ack = text;
|
|
1258
|
+
state.acked = true;
|
|
1259
|
+
let sent;
|
|
1260
|
+
try {
|
|
1261
|
+
sent = working(text);
|
|
1262
|
+
}
|
|
1263
|
+
catch (error) {
|
|
1264
|
+
sent = Promise.reject(error);
|
|
1265
|
+
}
|
|
1266
|
+
// Every call of the turn waits for it before it hands over a link, and a failed send costs only the reply.
|
|
1267
|
+
state.ackOut = Promise.resolve(sent).then(() => { state.ackDelivered = true; }, () => { });
|
|
1268
|
+
}
|
|
1269
|
+
// An ack writer that wrote nothing for a new request leaves the slow call to ask it again, with the model's words.
|
|
1270
|
+
}
|
|
1271
|
+
const links = state.links;
|
|
1272
|
+
const results = state.results;
|
|
1273
|
+
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
1274
|
+
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
1275
|
+
let handed = null;
|
|
1276
|
+
const shopperWords = String(shopperText ?? '');
|
|
1277
|
+
/**
|
|
1278
|
+
* The model's messages for this turn. A kept message whose text the history holds more often than
|
|
1279
|
+
* when the message first went in reached the model on a turn whose history the app saved, so the
|
|
1280
|
+
* client forgets it. One the history lacks, as after a turn that failed before its history was
|
|
1281
|
+
* saved, goes in again.
|
|
1282
|
+
*/
|
|
1283
|
+
const messagesFor = (history) => {
|
|
1284
|
+
const held = historyText(history);
|
|
1285
|
+
const saved = [];
|
|
1286
|
+
const fresh = [];
|
|
1287
|
+
for (const message of kept) {
|
|
1288
|
+
const text = modelText(message);
|
|
1289
|
+
if (text === '') {
|
|
1290
|
+
saved.push(message);
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
// Another order's update can read the same, so only a history holding the text more often than
|
|
1294
|
+
// when this message first went in holds this message.
|
|
1295
|
+
const count = occurrences(held, JSON.stringify(text).slice(1, -1));
|
|
1296
|
+
const before = offeredAt.get(message);
|
|
1297
|
+
if (before !== undefined && count > before) {
|
|
1298
|
+
saved.push(message);
|
|
1299
|
+
}
|
|
1300
|
+
else {
|
|
1301
|
+
if (before === undefined)
|
|
1302
|
+
offeredAt.set(message, count);
|
|
1303
|
+
fresh.push(message);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (saved.length > 0)
|
|
1307
|
+
client.forgetMessages(externalUserId, saved);
|
|
1308
|
+
handed = fresh;
|
|
1309
|
+
return turnMessages(history, fresh.map(modelText), shopperWords);
|
|
1310
|
+
};
|
|
1311
|
+
return toolOf((raw) => answerIn(raw, state), {}, {
|
|
1312
|
+
agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
|
|
1313
|
+
messages: { value: messagesFor, enumerable: false },
|
|
1314
|
+
ack: { value: ack, enumerable: false },
|
|
1315
|
+
reaction: { get: () => state.reaction, enumerable: false },
|
|
1316
|
+
links: { get: () => [...links], enumerable: false },
|
|
1317
|
+
results: { get: () => [...results], enumerable: false },
|
|
1318
|
+
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
1319
|
+
// 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
|
+
messageStream: { value: () => feedStream(text), enumerable: false },
|
|
1321
|
+
say: {
|
|
1322
|
+
value: (whole) => {
|
|
1323
|
+
const reply = feedReply(text);
|
|
1324
|
+
for (const message of toMessages(whole))
|
|
1325
|
+
reply.add(message);
|
|
1326
|
+
return reply.settled();
|
|
1327
|
+
},
|
|
1328
|
+
enumerable: false,
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1331
|
+
};
|
|
1332
|
+
return toolOf(answer, { instantAck, instantReaction, turn }, {});
|
|
899
1333
|
}
|