@agent-cards/commerce-sdk 0.2.0 → 0.8.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 +157 -4
- package/dist/ack.d.ts +51 -0
- package/dist/ack.js +166 -0
- package/dist/adapters.d.ts +4 -1
- package/dist/adapters.js +1 -0
- package/dist/buy-tool.d.ts +63 -3
- package/dist/buy-tool.js +227 -21
- package/dist/client.d.ts +78 -7
- package/dist/client.js +147 -13
- package/dist/definition.d.ts +15 -4
- package/dist/definition.js +20 -7
- package/dist/events.d.ts +12 -0
- package/dist/events.js +79 -0
- package/dist/handler.d.ts +58 -5
- package/dist/handler.js +207 -46
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -2
- package/dist/listen.d.ts +93 -0
- package/dist/listen.js +496 -0
- package/dist/message.d.ts +3 -1
- package/dist/model-view.d.ts +25 -5
- package/dist/model-view.js +48 -10
- package/dist/shopper-message.d.ts +86 -0
- package/dist/shopper-message.js +175 -0
- package/package.json +2 -2
package/dist/buy-tool.js
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { AgentcardApiError, AgentcardClient, } from './client.js';
|
|
26
26
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
27
|
-
import { forModel, forModelError, money, shownProducts } from './model-view.js';
|
|
27
|
+
import { forModel, forModelError, money, pickId, shownProducts } from './model-view.js';
|
|
28
28
|
import { toMessages } from './messages.js';
|
|
29
|
+
import { ackAt, instantAck as instantAckOf, isNewRequest, nextTemplate } from './ack.js';
|
|
29
30
|
/** The pause before the one read that follows a placement Agentcard is still finishing. */
|
|
30
31
|
const PLACEMENT_WAIT_MS = 12_000;
|
|
31
32
|
/** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
|
|
@@ -41,6 +42,12 @@ const RESUME_SKEW_MS = 2 * 60_000;
|
|
|
41
42
|
const STOPPED_ORDER = new Set(['failed', 'cancelled']);
|
|
42
43
|
/** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
|
|
43
44
|
const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
|
|
45
|
+
/** How long Agentcard has to answer before the instant reply goes out. */
|
|
46
|
+
const ACK_DELAY_MS = 1_500;
|
|
47
|
+
/** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
|
|
48
|
+
const SAME_TURN_MS = 15_000;
|
|
49
|
+
/** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
|
|
50
|
+
const APP_TURN_MS = 120_000;
|
|
44
51
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
45
52
|
const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
|
|
46
53
|
const STILL_PLACING = 'The shopper approved, and the order is still going through.';
|
|
@@ -52,20 +59,37 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
|
|
|
52
59
|
const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
|
|
53
60
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
54
61
|
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.';
|
|
62
|
+
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
|
+
/**
|
|
64
|
+
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
65
|
+
* the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
|
|
66
|
+
* its store page, as its page. A pick already in the cart, or none, stays as it was.
|
|
67
|
+
*/
|
|
68
|
+
function pickAfter(pick, carts) {
|
|
69
|
+
if (!pick || pick.in_cart)
|
|
70
|
+
return pick;
|
|
71
|
+
const held = carts.some((c) => c.items.some((line) => line.product_id === pick.product_id || line.url === pick.product_id));
|
|
72
|
+
return held ? { ...pick, in_cart: true } : pick;
|
|
73
|
+
}
|
|
55
74
|
function hashesOf(cart, carts) {
|
|
56
75
|
return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
|
|
57
76
|
}
|
|
58
77
|
function productsOf(turn) {
|
|
59
|
-
return (turn.catalog
|
|
78
|
+
return turn.catalog ? productsOfCatalog(turn.catalog) : [];
|
|
79
|
+
}
|
|
80
|
+
/** The products of a search, as the tool reads them. */
|
|
81
|
+
function productsOfCatalog(catalog) {
|
|
82
|
+
return (catalog.items ?? [])
|
|
60
83
|
.filter((item) => item.name)
|
|
61
84
|
.map((item) => ({
|
|
62
85
|
id: item.id,
|
|
86
|
+
ref: item.ref,
|
|
63
87
|
name: item.name,
|
|
64
88
|
priceCents: item.priceCents,
|
|
65
89
|
currency: item.currency,
|
|
66
90
|
imageUrl: item.image_url,
|
|
67
91
|
...(item.flight ? { flight: item.flight } : {}),
|
|
68
|
-
store: item.store?.name ?? item.store?.id ??
|
|
92
|
+
store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
|
|
69
93
|
url: item.url ?? null,
|
|
70
94
|
linkSent: false,
|
|
71
95
|
}));
|
|
@@ -94,6 +118,7 @@ function answerOf(turn) {
|
|
|
94
118
|
decline_code: turn.decline_code,
|
|
95
119
|
error_code: turn.error_code,
|
|
96
120
|
next_action: turn.next_action,
|
|
121
|
+
pick: turn.pick,
|
|
97
122
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
98
123
|
envelope: turn.raw,
|
|
99
124
|
};
|
|
@@ -191,6 +216,7 @@ function resultOfRefusal(error) {
|
|
|
191
216
|
decline_code: null,
|
|
192
217
|
error_code: error.code,
|
|
193
218
|
next_action: null,
|
|
219
|
+
pick: null,
|
|
194
220
|
hashes: hashesOf(null, carts),
|
|
195
221
|
envelope: error.body,
|
|
196
222
|
});
|
|
@@ -202,6 +228,8 @@ function resultOfRefusal(error) {
|
|
|
202
228
|
return answer(NOTHING_TO_CONFIRM, []);
|
|
203
229
|
if (error.code === 'turn_in_progress')
|
|
204
230
|
return answer(STILL_WORKING, []);
|
|
231
|
+
if (error.code === 'pick_not_found')
|
|
232
|
+
return answer(PICK_GONE, []);
|
|
205
233
|
return null;
|
|
206
234
|
}
|
|
207
235
|
/** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
|
|
@@ -222,6 +250,7 @@ function needsCard(link, body) {
|
|
|
222
250
|
decline_code: null,
|
|
223
251
|
error_code: 'user_not_connected',
|
|
224
252
|
next_action: 'add_card',
|
|
253
|
+
pick: null,
|
|
225
254
|
hashes: [],
|
|
226
255
|
envelope: body,
|
|
227
256
|
};
|
|
@@ -295,10 +324,103 @@ export function buyTool(options) {
|
|
|
295
324
|
throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
|
|
296
325
|
}
|
|
297
326
|
const sendLink = o.sendLink;
|
|
327
|
+
if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
|
|
328
|
+
throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
|
|
329
|
+
if (o.stream !== undefined && o.stream !== false)
|
|
330
|
+
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
331
|
+
const onProgress = o.onProgress;
|
|
332
|
+
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
333
|
+
throw new TypeError('Pass earlyCards as true or false.');
|
|
334
|
+
const earlyCards = o.earlyCards === true && !!o.sendLink;
|
|
335
|
+
const streams = o.stream !== false && (earlyCards || !!onProgress);
|
|
336
|
+
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
337
|
+
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
338
|
+
}
|
|
339
|
+
const onResult = o.onResult;
|
|
298
340
|
const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
299
341
|
if (typeof wait !== 'function')
|
|
300
342
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
301
343
|
const who = client.company ? { externalUserId } : {};
|
|
344
|
+
if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
|
|
345
|
+
throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
|
|
346
|
+
if (o.ack !== undefined && o.ack !== false && typeof o.ack !== 'function')
|
|
347
|
+
throw new TypeError('Pass ack as false, or as a function that writes the instant reply from the ask.');
|
|
348
|
+
const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
|
|
349
|
+
if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
|
|
350
|
+
throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
|
|
351
|
+
const onWorking = o.ack === false ? undefined : o.onWorking;
|
|
352
|
+
const writeAck = typeof o.ack === 'function' ? o.ack : null;
|
|
353
|
+
/** Calls running now, when the last one ended, whether this turn's instant reply is taken, and the template it used last. */
|
|
354
|
+
let running = 0;
|
|
355
|
+
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
356
|
+
let turnAcked = false;
|
|
357
|
+
let lastTemplate = -1;
|
|
358
|
+
/** Counts the model's turns: the calls of one turn share its number. */
|
|
359
|
+
let modelTurn = 0;
|
|
360
|
+
/** This turn's instant reply while it is being sent: every call of the turn waits for it before it hands over a link. */
|
|
361
|
+
let ackOut = null;
|
|
362
|
+
/** True once `onWorking` delivered this turn's instant reply, so the model is told not to acknowledge again. */
|
|
363
|
+
let ackDelivered = false;
|
|
364
|
+
/**
|
|
365
|
+
* The app's instant reply for the shopper's newest message, waiting for the model's first call on that
|
|
366
|
+
* message, and the step the last answer asked the shopper for. A call still running for an earlier
|
|
367
|
+
* message keeps its own turn's state.
|
|
368
|
+
*/
|
|
369
|
+
let appAck = null;
|
|
370
|
+
let lastNextAction = null;
|
|
371
|
+
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
372
|
+
const instantAck = (shopperText) => {
|
|
373
|
+
const text = String(shopperText ?? '');
|
|
374
|
+
const said = instantAckOf(text, { nextAction: lastNextAction }) !== null;
|
|
375
|
+
// The same rotation onWorking uses, so the next reply comes from another template.
|
|
376
|
+
if (said)
|
|
377
|
+
lastTemplate = nextTemplate(lastTemplate);
|
|
378
|
+
appAck = { at: Date.now(), said };
|
|
379
|
+
return said ? ackAt(lastTemplate, text) : null;
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
383
|
+
* not gone out, and frees the turn's reply for a later call.
|
|
384
|
+
*/
|
|
385
|
+
const holdAck = (ask) => {
|
|
386
|
+
if (!onWorking || turnAcked || !isNewRequest(ask))
|
|
387
|
+
return null;
|
|
388
|
+
turnAcked = true;
|
|
389
|
+
let held = true;
|
|
390
|
+
const timer = setTimeout(() => {
|
|
391
|
+
held = false;
|
|
392
|
+
let text;
|
|
393
|
+
if (writeAck) {
|
|
394
|
+
try {
|
|
395
|
+
text = writeAck(ask);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
text = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
lastTemplate = nextTemplate(lastTemplate);
|
|
403
|
+
text = ackAt(lastTemplate, ask);
|
|
404
|
+
}
|
|
405
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
406
|
+
turnAcked = false;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const message = text.trim();
|
|
410
|
+
ackOut = Promise.resolve()
|
|
411
|
+
.then(() => onWorking(message))
|
|
412
|
+
.then(() => { ackDelivered = true; }, () => { });
|
|
413
|
+
}, ackDelayMs);
|
|
414
|
+
return {
|
|
415
|
+
cancel: () => {
|
|
416
|
+
if (!held)
|
|
417
|
+
return;
|
|
418
|
+
held = false;
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
turnAcked = false;
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
};
|
|
302
424
|
/** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
|
|
303
425
|
const read = async (conversationId) => {
|
|
304
426
|
try {
|
|
@@ -456,6 +578,8 @@ export function buyTool(options) {
|
|
|
456
578
|
error_code: newest ? null : base.error_code,
|
|
457
579
|
// A read replaced the answer the step was named for, and names no step of its own: the reply asks.
|
|
458
580
|
next_action: null,
|
|
581
|
+
// The early answer judged the pick before the turn finished adding it.
|
|
582
|
+
pick: pickAfter(base.pick, conversation.carts),
|
|
459
583
|
envelope: conversation.raw,
|
|
460
584
|
};
|
|
461
585
|
if (!conversation.turn_in_progress)
|
|
@@ -512,15 +636,21 @@ export function buyTool(options) {
|
|
|
512
636
|
// A running turn that placed nothing may be a search rather than a placement, so the shopper hears it is still working.
|
|
513
637
|
return settled.status === 'placing' && !placing ? refused : settled;
|
|
514
638
|
};
|
|
515
|
-
/** The store pages this tool sent last, with their conversation. */
|
|
639
|
+
/** The store pages this tool sent last, with their conversation, the model turn that sent them, and the search they list. */
|
|
516
640
|
let lastPages = null;
|
|
517
641
|
/**
|
|
518
642
|
* The store page of each product the model reads, sent once per page. A
|
|
519
643
|
* page the last batch in this conversation already sent stays with the
|
|
520
|
-
* shopper and goes out no second time
|
|
644
|
+
* shopper and goes out no second time, unless a later model turn's reply
|
|
645
|
+
* presents the products again (the catalog's `presented`): a repeated ask
|
|
646
|
+
* answered with the same products gets their pages beside it, so every
|
|
647
|
+
* product the reply names has a card. Calls of one model turn still send a
|
|
648
|
+
* page once. An answer that lists fewer of the same search's products, such
|
|
649
|
+
* as a pick's, keeps the pages that search already sent, so a later answer
|
|
650
|
+
* that lists them again sends none twice. A page whose `sendLink` throws
|
|
521
651
|
* stays unsent, and the call goes on, so the next answer tries it again.
|
|
522
652
|
*/
|
|
523
|
-
const deliverPages = async (result) => {
|
|
653
|
+
const deliverPages = async (result, call) => {
|
|
524
654
|
if (!sendLink)
|
|
525
655
|
return result;
|
|
526
656
|
const pages = new Map();
|
|
@@ -531,12 +661,21 @@ export function buyTool(options) {
|
|
|
531
661
|
// An answer with no store page leaves the last batch as it was.
|
|
532
662
|
if (pages.size === 0)
|
|
533
663
|
return result;
|
|
534
|
-
const
|
|
535
|
-
const
|
|
664
|
+
const kept = lastPages?.conversationId === result.conversation_id && (!call.presented || lastPages.turn === call.turn);
|
|
665
|
+
const before = kept && lastPages ? lastPages.urls : new Set();
|
|
666
|
+
// 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 page that search already sent, so a later answer
|
|
668
|
+
// that lists them again sends none twice.
|
|
669
|
+
const catalog = result.envelope?.catalog;
|
|
670
|
+
const search = typeof catalog?.as_of === 'string' ? catalog.as_of : null;
|
|
671
|
+
const sameSearch = kept && search !== null && lastPages?.search === search;
|
|
672
|
+
const held = new Set(sameSearch ? before : []);
|
|
536
673
|
for (const [url, product] of pages) {
|
|
537
674
|
if (!before.has(url)) {
|
|
538
675
|
try {
|
|
539
|
-
|
|
676
|
+
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
677
|
+
const productId = pickId(product);
|
|
678
|
+
await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
|
|
540
679
|
}
|
|
541
680
|
catch {
|
|
542
681
|
continue;
|
|
@@ -544,25 +683,27 @@ export function buyTool(options) {
|
|
|
544
683
|
}
|
|
545
684
|
held.add(url);
|
|
546
685
|
}
|
|
547
|
-
lastPages = { conversationId: result.conversation_id, urls: held };
|
|
686
|
+
lastPages = { conversationId: result.conversation_id, turn: call.turn, search, urls: held };
|
|
548
687
|
return { ...result, products: result.products.map((product) => (product.url && held.has(product.url) ? { ...product, linkSent: true } : product)) };
|
|
549
688
|
};
|
|
689
|
+
/** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
|
|
690
|
+
const earlyPages = (conversationId, asOf, products) => ({ conversation_id: conversationId, envelope: { catalog: { as_of: asOf } }, products });
|
|
550
691
|
/** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
|
|
551
692
|
let pagesInTurn = Promise.resolve();
|
|
552
|
-
const deliverPagesInTurn = (result) => {
|
|
553
|
-
const batch = pagesInTurn.then(() => deliverPages(result));
|
|
693
|
+
const deliverPagesInTurn = (result, call) => {
|
|
694
|
+
const batch = pagesInTurn.then(() => deliverPages(result, call));
|
|
554
695
|
pagesInTurn = batch.catch(() => { });
|
|
555
696
|
return batch;
|
|
556
697
|
};
|
|
557
698
|
/** The Vault or approval link first, because the purchase waits on it, then the store pages. */
|
|
558
|
-
const deliver = async (result) => {
|
|
699
|
+
const deliver = async (result, call) => {
|
|
559
700
|
let delivered = result;
|
|
560
701
|
if (result.links.length > 0 && sendLink) {
|
|
561
702
|
for (const link of result.links)
|
|
562
703
|
await sendLink({ ...link });
|
|
563
704
|
delivered = { ...result, link_sent: true };
|
|
564
705
|
}
|
|
565
|
-
return deliverPagesInTurn(delivered);
|
|
706
|
+
return deliverPagesInTurn(delivered, call);
|
|
566
707
|
};
|
|
567
708
|
const answer = async (raw) => {
|
|
568
709
|
const startedAt = Date.now();
|
|
@@ -572,22 +713,87 @@ export function buyTool(options) {
|
|
|
572
713
|
const ask = input.ask.trim();
|
|
573
714
|
if (!confirm && ask === '')
|
|
574
715
|
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 pick = !confirm && !input.new_order && input.product_id ? { product_id: input.product_id } : null;
|
|
575
718
|
const body = {
|
|
576
719
|
resume: true,
|
|
577
720
|
...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
|
|
578
721
|
...(input.new_order ? { new_order: true } : {}),
|
|
722
|
+
...(pick ? { pick } : {}),
|
|
579
723
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
580
724
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
581
725
|
};
|
|
582
|
-
|
|
583
|
-
|
|
726
|
+
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
727
|
+
// 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
|
+
const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
|
|
730
|
+
appAck = null;
|
|
731
|
+
if (opened) {
|
|
732
|
+
turnAcked = true;
|
|
733
|
+
ackOut = null;
|
|
734
|
+
ackDelivered = opened.said;
|
|
735
|
+
modelTurn += 1;
|
|
736
|
+
}
|
|
737
|
+
else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
|
|
738
|
+
turnAcked = false;
|
|
739
|
+
ackOut = null;
|
|
740
|
+
ackDelivered = false;
|
|
741
|
+
modelTurn += 1;
|
|
742
|
+
}
|
|
743
|
+
const callTurn = modelTurn;
|
|
744
|
+
running += 1;
|
|
745
|
+
// A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
|
|
746
|
+
const ack = confirm || pick ? null : holdAck(ask);
|
|
584
747
|
try {
|
|
585
|
-
turn =
|
|
748
|
+
let turn = null;
|
|
749
|
+
let refusal;
|
|
750
|
+
// Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
|
|
751
|
+
const handlers = streams
|
|
752
|
+
? {
|
|
753
|
+
...(onProgress ? { onProgress: (progress) => { try {
|
|
754
|
+
onProgress(progress);
|
|
755
|
+
}
|
|
756
|
+
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
757
|
+
...(earlyCards && sendLink && !confirm
|
|
758
|
+
? {
|
|
759
|
+
onCatalog: async (update) => {
|
|
760
|
+
// Products arrived before the instant reply was due: they are the reply.
|
|
761
|
+
ack?.cancel();
|
|
762
|
+
await ackOut;
|
|
763
|
+
// 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 });
|
|
765
|
+
},
|
|
766
|
+
}
|
|
767
|
+
: {}),
|
|
768
|
+
}
|
|
769
|
+
: undefined;
|
|
770
|
+
try {
|
|
771
|
+
turn = await client.buy(body, handlers);
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
refusal = error;
|
|
775
|
+
}
|
|
776
|
+
ack?.cancel();
|
|
777
|
+
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;
|
|
781
|
+
lastNextAction = result.next_action;
|
|
782
|
+
if (onResult) {
|
|
783
|
+
try {
|
|
784
|
+
await onResult(result);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
// The app's display failing never fails the purchase call.
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return result;
|
|
586
791
|
}
|
|
587
|
-
|
|
588
|
-
|
|
792
|
+
finally {
|
|
793
|
+
ack?.cancel();
|
|
794
|
+
running -= 1;
|
|
795
|
+
lastEndedAt = Date.now();
|
|
589
796
|
}
|
|
590
|
-
return deliver(withMessages(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt), turn));
|
|
591
797
|
};
|
|
592
798
|
/** Checks the model's input, with every link taken out of a refusal. */
|
|
593
799
|
const parse = (input) => {
|
|
@@ -608,7 +814,7 @@ export function buyTool(options) {
|
|
|
608
814
|
}
|
|
609
815
|
};
|
|
610
816
|
const { name, description, input_schema } = buyToolDefinition();
|
|
611
|
-
const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel };
|
|
817
|
+
const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel, instantAck };
|
|
612
818
|
// JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
|
|
613
819
|
const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
|
|
614
820
|
return Object.defineProperties(tool, {
|
package/dist/client.d.ts
CHANGED
|
@@ -1,14 +1,55 @@
|
|
|
1
|
+
import { type AgentcardListener, type ListenOptions } from './listen.js';
|
|
1
2
|
/** The fetch the client calls: Node 18 and later have one built in. */
|
|
2
3
|
export type FetchLike = (url: string, init: {
|
|
3
4
|
method: string;
|
|
4
5
|
headers: Record<string, string>;
|
|
5
6
|
body?: string;
|
|
6
7
|
signal?: AbortSignal;
|
|
7
|
-
}) => Promise<
|
|
8
|
+
}) => Promise<FetchLikeResponse>;
|
|
9
|
+
/**
|
|
10
|
+
* The answer a fetch returns, as far as the client reads it. `headers` and `body` are optional: a
|
|
11
|
+
* fetch without them is read whole with `text()`, and a streamed purchase answer still arrives, at its end.
|
|
12
|
+
*/
|
|
13
|
+
export interface FetchLikeResponse {
|
|
8
14
|
status: number;
|
|
9
15
|
ok: boolean;
|
|
10
16
|
text(): Promise<string>;
|
|
11
|
-
|
|
17
|
+
headers?: {
|
|
18
|
+
get(name: string): string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
body?: {
|
|
21
|
+
getReader(): {
|
|
22
|
+
read(): Promise<{
|
|
23
|
+
done: boolean;
|
|
24
|
+
value?: Uint8Array;
|
|
25
|
+
}>;
|
|
26
|
+
cancel?(): Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
} | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What the purchase turn is doing while it runs: `searching` the stores, `building_cart`, `checking_out`,
|
|
32
|
+
* or `working` on anything else. Agentcard names only the stage, never what the shopper said.
|
|
33
|
+
*/
|
|
34
|
+
export type BuyProgressStage = 'searching' | 'building_cart' | 'checking_out' | 'working';
|
|
35
|
+
/** One step of a running purchase turn. */
|
|
36
|
+
export interface BuyProgress {
|
|
37
|
+
stage: BuyProgressStage;
|
|
38
|
+
conversationId: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** The products a running turn's search found, sent the moment the search answers and before the reply is written. */
|
|
41
|
+
export interface BuyCatalogUpdate {
|
|
42
|
+
conversationId: string;
|
|
43
|
+
catalog: BuyCatalog;
|
|
44
|
+
/** When the search ran, as the answer's `catalog.as_of` names it: the same search in the answer has the same value. */
|
|
45
|
+
asOf: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** Called while a purchase turn runs. Passing either one asks Agentcard to stream the turn. */
|
|
48
|
+
export interface BuyStreamHandlers {
|
|
49
|
+
onProgress?: (progress: BuyProgress) => void;
|
|
50
|
+
/** The client waits for what this returns before it hands back the turn's answer. */
|
|
51
|
+
onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
|
|
52
|
+
}
|
|
12
53
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
13
54
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
14
55
|
export declare const BUY_TURN_TIMEOUT_MS = 130000;
|
|
@@ -51,6 +92,13 @@ export interface BuyRequest {
|
|
|
51
92
|
resume?: true;
|
|
52
93
|
/** Drop the cart on the table and start a new order. */
|
|
53
94
|
new_order?: true;
|
|
95
|
+
/**
|
|
96
|
+
* The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
|
|
97
|
+
* Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
|
|
98
|
+
*/
|
|
99
|
+
pick?: {
|
|
100
|
+
product_id: string;
|
|
101
|
+
};
|
|
54
102
|
payment_source?: 'vault';
|
|
55
103
|
delivery_address?: BuyDeliveryAddress;
|
|
56
104
|
external_user_id?: string;
|
|
@@ -101,6 +149,8 @@ export interface BuyFlightItinerary {
|
|
|
101
149
|
export interface BuyCatalogItem {
|
|
102
150
|
flight?: BuyFlightItinerary;
|
|
103
151
|
id: string;
|
|
152
|
+
/** The item's id with no link in it, which `pick.product_id` takes as it takes `id`; null from an older API. */
|
|
153
|
+
ref: string | null;
|
|
104
154
|
name: string | null;
|
|
105
155
|
priceCents: number | null;
|
|
106
156
|
/** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
|
|
@@ -110,11 +160,22 @@ export interface BuyCatalogItem {
|
|
|
110
160
|
/** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
|
|
111
161
|
url: string | null;
|
|
112
162
|
}
|
|
113
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* The products the last search found. `presented` is true when this answer's reply presents them: the
|
|
165
|
+
* turn searched, or answered a repeated ask from the results it already held. False when they only
|
|
166
|
+
* stand from an earlier answer, and on an older API.
|
|
167
|
+
*/
|
|
114
168
|
export interface BuyCatalog {
|
|
115
169
|
merchant: string;
|
|
116
170
|
merchant_name: string;
|
|
117
171
|
items: BuyCatalogItem[];
|
|
172
|
+
presented: boolean;
|
|
173
|
+
}
|
|
174
|
+
/** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
|
|
175
|
+
export interface BuyPick {
|
|
176
|
+
product_id: string;
|
|
177
|
+
ref: string | null;
|
|
178
|
+
in_cart: boolean;
|
|
118
179
|
}
|
|
119
180
|
export interface BuyPaymentSource {
|
|
120
181
|
source: string;
|
|
@@ -160,6 +221,8 @@ export interface BuyTurn {
|
|
|
160
221
|
store_login_expires_at: string | null;
|
|
161
222
|
/** The store that page signs in to. */
|
|
162
223
|
store_login_merchant_name: string | null;
|
|
224
|
+
/** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
|
|
225
|
+
pick: BuyPick | null;
|
|
163
226
|
/** The answer as Agentcard sent it. */
|
|
164
227
|
raw: Record<string, unknown>;
|
|
165
228
|
}
|
|
@@ -211,7 +274,7 @@ export interface VaultLink {
|
|
|
211
274
|
/**
|
|
212
275
|
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
213
276
|
* (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
|
|
214
|
-
* `user_id_mismatch`), one read from the shape of the answer
|
|
277
|
+
* `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
|
|
215
278
|
* (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
|
|
216
279
|
* `conversation_not_found`), or the client's (`unreachable`, `timeout`,
|
|
217
280
|
* `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
|
|
@@ -243,15 +306,18 @@ export declare class AgentcardClient {
|
|
|
243
306
|
get company(): boolean;
|
|
244
307
|
/** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
|
|
245
308
|
get sandbox(): boolean | null;
|
|
246
|
-
/**
|
|
247
|
-
|
|
309
|
+
/**
|
|
310
|
+
* The access token the client sends: the shopper's own, or the company's, exchanged once and
|
|
311
|
+
* renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
|
|
312
|
+
*/
|
|
313
|
+
bearer(refused?: string): Promise<string>;
|
|
248
314
|
/**
|
|
249
315
|
* One purchase turn, sent once with a 130-second wait. A turn is a model run
|
|
250
316
|
* against live stores, so a lost answer is never sent again: read the
|
|
251
317
|
* conversation instead. Only a 401 is sent again, once, with a fresh token,
|
|
252
318
|
* because Agentcard refused it before anything ran.
|
|
253
319
|
*/
|
|
254
|
-
buy(body: BuyRequest): Promise<BuyTurn>;
|
|
320
|
+
buy(body: BuyRequest, stream?: BuyStreamHandlers): Promise<BuyTurn>;
|
|
255
321
|
/** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
|
|
256
322
|
conversation(conversationId: string, who?: Shopper): Promise<BuyConversation>;
|
|
257
323
|
/**
|
|
@@ -262,6 +328,11 @@ export declare class AgentcardClient {
|
|
|
262
328
|
mintVaultLink(input: {
|
|
263
329
|
externalUserId: string;
|
|
264
330
|
}): Promise<VaultLink>;
|
|
331
|
+
/**
|
|
332
|
+
* Hear every event Agentcard sends your company over one connection your server opens, with no
|
|
333
|
+
* webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
|
|
334
|
+
*/
|
|
335
|
+
listen(opts: ListenOptions): AgentcardListener;
|
|
265
336
|
/** A company names the shopper exactly once; a shopper's own token already names them. */
|
|
266
337
|
private checkShopper;
|
|
267
338
|
private call;
|