@agent-cards/commerce-sdk 0.0.0-stage → 0.2.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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * The buy tool: one call that buys for one shopper, in one object that every
3
+ * agent framework takes. `answer` sends the model's input to Agentcard and
4
+ * returns the whole answer for your code, with the status, Agentcard's
5
+ * reply, the cart and its hash, the products and the links. `forModel` turns
6
+ * that answer into what the model may read, with every link taken out, and
7
+ * `run`, `execute` and `handle` return that view as JSON text. A link
8
+ * reaches the shopper through `sendLink`, and each call waits for it before
9
+ * it returns: the Vault and approval links, then the store page of each
10
+ * product the model reads.
11
+ *
12
+ * Agentcard keeps the conversation: every call continues the shopper's open
13
+ * purchase (`resume: true`), and a confirm places the cart Agentcard last
14
+ * showed (`confirm: true`). The tool keeps one thing between calls: the store
15
+ * pages it sent last. Agentcard repeats the last search in each answer for a
16
+ * while, so a list it shows again in the same conversation does not reach
17
+ * the shopper twice. A tool built for each message buys the same way, and
18
+ * sends such a list again.
19
+ *
20
+ * Two answers make the tool wait before it returns. A placement Agentcard is
21
+ * still finishing gets one 12-second pause and one read of the conversation.
22
+ * A turn that outlived Agentcard's budget gets a read every 5 seconds for up
23
+ * to a minute, until the turn ends.
24
+ */
25
+ import { AgentcardApiError, AgentcardClient, } from './client.js';
26
+ import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
27
+ import { forModel, forModelError, money, shownProducts } from './model-view.js';
28
+ import { toMessages } from './messages.js';
29
+ /** The pause before the one read that follows a placement Agentcard is still finishing. */
30
+ const PLACEMENT_WAIT_MS = 12_000;
31
+ /** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
32
+ const TURN_READ_EVERY_MS = 5_000;
33
+ const TURN_READS = 12;
34
+ /**
35
+ * How long before an `in_progress` checkout the order it waits on may have been placed: the
36
+ * approval's own resume can place the order a moment before a second attempt records that the
37
+ * placement is still running.
38
+ */
39
+ const RESUME_SKEW_MS = 2 * 60_000;
40
+ /** Ledger states of an order that stopped after it was placed: its payment never confirmed, or the store cancelled it. */
41
+ const STOPPED_ORDER = new Set(['failed', 'cancelled']);
42
+ /** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
43
+ const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
44
+ const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
45
+ const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
46
+ const STILL_PLACING = 'The shopper approved, and the order is still going through.';
47
+ const NOT_PLACED = 'The order did not go through.';
48
+ const CART_READY = 'The cart is ready. Show the shopper the cart before you confirm it.';
49
+ const CHOOSE_CART = 'The shopper has carts at more than one store. Ask the shopper which cart they want, and send their answer in ask before you confirm.';
50
+ const APPROVAL_WAITING = 'The purchase waits for the shopper to approve it from the approval link.';
51
+ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this tool again with the shopper's last words.";
52
+ const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
53
+ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
54
+ 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.';
55
+ function hashesOf(cart, carts) {
56
+ return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
57
+ }
58
+ function productsOf(turn) {
59
+ return (turn.catalog?.items ?? [])
60
+ .filter((item) => item.name)
61
+ .map((item) => ({
62
+ id: item.id,
63
+ name: item.name,
64
+ priceCents: item.priceCents,
65
+ currency: item.currency,
66
+ imageUrl: item.image_url,
67
+ ...(item.flight ? { flight: item.flight } : {}),
68
+ store: item.store?.name ?? item.store?.id ?? turn.catalog?.merchant_name ?? null,
69
+ url: item.url ?? null,
70
+ linkSent: false,
71
+ }));
72
+ }
73
+ /** The store login link an answer carries, read from its own fields: a link in `reply` is never pulled out. */
74
+ function storeLoginOf(turn) {
75
+ if (!turn.store_login_url)
76
+ return [];
77
+ return [{ kind: 'store_login', url: turn.store_login_url, expiresAt: turn.store_login_expires_at, store: turn.store_login_merchant_name }];
78
+ }
79
+ /** The answer before its status is read: every field the purchase answer carries. */
80
+ function answerOf(turn) {
81
+ return {
82
+ status: 'needs_input',
83
+ reply: turn.reply,
84
+ messages: [],
85
+ cart: turn.cart,
86
+ carts: turn.carts,
87
+ products: productsOf(turn),
88
+ links: storeLoginOf(turn),
89
+ link_sent: false,
90
+ approval_expires_at: turn.approval_expires_at,
91
+ shopper_need_not_reply: false,
92
+ conversation_id: turn.conversation_id || null,
93
+ order_id: turn.order_id,
94
+ decline_code: turn.decline_code,
95
+ error_code: turn.error_code,
96
+ next_action: turn.next_action,
97
+ hashes: hashesOf(turn.cart, turn.carts),
98
+ envelope: turn.raw,
99
+ };
100
+ }
101
+ /**
102
+ * The answer that waits on the shopper's card, when the turn carries Agentcard's add-card link: a
103
+ * checkout refused on an empty vault (`vault_unavailable`), or the agent made the link while it
104
+ * asked. The status beside it does not decide: the link is the one way forward either way, and the
105
+ * reply has every link taken out. Null when the turn carries no link, or placed an order.
106
+ */
107
+ function needsCardOf(turn, answer) {
108
+ // Agentcard named another step first: the card waits until that one is done.
109
+ if (turn.next_action && turn.next_action !== 'add_card')
110
+ return null;
111
+ if (!turn.add_card_url || turn.status === 'order_placed' || turn.status === 'partially_placed' || turn.status === 'needs_approval')
112
+ return null;
113
+ const refused = turn.status === 'declined';
114
+ // A refusal with another code is about something other than a card, and keeps its own status.
115
+ if (refused && turn.decline_code !== 'vault_unavailable')
116
+ return null;
117
+ if (!refused && turn.decline_code && turn.decline_code !== 'vault_unavailable')
118
+ return null;
119
+ return { ...answer, status: 'needs_card', links: [...answer.links, { kind: 'vault', url: turn.add_card_url, expiresAt: turn.add_card_expires_at }] };
120
+ }
121
+ /**
122
+ * The answer with only the link of the step the shopper takes now, when Agentcard names one
123
+ * (`next_action`): the Vault link waits for `add_card`, the approval link for `approve`. A store login
124
+ * link and product pages are not steps, so they stay. An answer that names no step keeps every link.
125
+ */
126
+ function onlyNextStepLinks(result, next) {
127
+ if (!next)
128
+ return result;
129
+ const links = result.links.filter((link) => (link.kind === 'vault' ? next === 'add_card' : link.kind === 'approval' ? next === 'approve' : true));
130
+ return links.length === result.links.length ? result : { ...result, links };
131
+ }
132
+ /**
133
+ * The answer's `messages`, read once its `reply` is final: the messages Agentcard sent while the reply
134
+ * is still Agentcard's own, else the reply the tool wrote, each cut at its blank lines.
135
+ */
136
+ function withMessages(result, turn) {
137
+ const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
138
+ return { ...result, messages: sent.flatMap(toMessages) };
139
+ }
140
+ /** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
141
+ function resultOfTurn(turn) {
142
+ return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
143
+ }
144
+ /** A purchase answer, read for its status, with every link the answer carries. */
145
+ function resultOfStatus(turn) {
146
+ const answer = answerOf(turn);
147
+ const card = needsCardOf(turn, answer);
148
+ if (card)
149
+ return card;
150
+ switch (turn.status) {
151
+ case 'order_placed':
152
+ case 'partially_placed':
153
+ return { ...answer, status: 'order_placed' };
154
+ case 'needs_approval':
155
+ return { ...answer, status: 'approval_sent' };
156
+ case 'declined':
157
+ if (turn.decline_code === 'vault_approval_required') {
158
+ // The shopper already opened the approval and checkout is placing: a second link would ask for a second approval.
159
+ if (turn.approval_in_progress === true) {
160
+ return { ...answer, status: 'placing', reply: turn.reply || STILL_PLACING, shopper_need_not_reply: turn.placement_announced === true };
161
+ }
162
+ return {
163
+ ...answer,
164
+ status: 'approval_sent',
165
+ links: [...answer.links, ...(turn.approval_url ? [{ kind: 'approval', url: turn.approval_url, expiresAt: turn.approval_expires_at }] : [])],
166
+ shopper_need_not_reply: turn.placement_announced === true,
167
+ };
168
+ }
169
+ return { ...answer, status: 'declined' };
170
+ default:
171
+ return answer;
172
+ }
173
+ }
174
+ /** A refusal that still answers the shopper: a changed cart, nothing to confirm, a turn still running. Null for every other error. */
175
+ function resultOfRefusal(error) {
176
+ if (!(error instanceof AgentcardApiError) || error.status !== 409)
177
+ return null;
178
+ const answer = (reply, carts) => ({
179
+ status: 'needs_input',
180
+ reply,
181
+ messages: [],
182
+ cart: carts.at(-1) ?? null,
183
+ carts,
184
+ products: [],
185
+ links: [],
186
+ link_sent: false,
187
+ approval_expires_at: null,
188
+ shopper_need_not_reply: false,
189
+ conversation_id: error.conversationId,
190
+ order_id: null,
191
+ decline_code: null,
192
+ error_code: error.code,
193
+ next_action: null,
194
+ hashes: hashesOf(null, carts),
195
+ envelope: error.body,
196
+ });
197
+ if (error.code.startsWith('delivery_address'))
198
+ return answer(ADDRESS_CHANGED, []);
199
+ if (error.code === 'cart_changed')
200
+ return answer(CART_CHANGED, error.carts);
201
+ if (error.code === 'nothing_to_confirm' || error.code === 'conversation_closed')
202
+ return answer(NOTHING_TO_CONFIRM, []);
203
+ if (error.code === 'turn_in_progress')
204
+ return answer(STILL_WORKING, []);
205
+ return null;
206
+ }
207
+ /** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
208
+ function needsCard(link, body) {
209
+ return {
210
+ status: 'needs_card',
211
+ reply: CARD_NEEDED,
212
+ messages: [],
213
+ cart: null,
214
+ carts: [],
215
+ products: [],
216
+ links: [{ kind: 'vault', url: link.url, expiresAt: link.expiresAt }],
217
+ link_sent: false,
218
+ approval_expires_at: null,
219
+ shopper_need_not_reply: false,
220
+ conversation_id: null,
221
+ order_id: null,
222
+ decline_code: null,
223
+ error_code: 'user_not_connected',
224
+ next_action: 'add_card',
225
+ hashes: [],
226
+ envelope: body,
227
+ };
228
+ }
229
+ /**
230
+ * The order this purchase's checkout produced: the last checkout's own order, or the newest order
231
+ * placed since the last checkout, one that did not stop first. Agentcard clears the last checkout when
232
+ * a turn starts, so an order older than it came from an earlier cart. With no last checkout, only an
233
+ * order placed since `since`, the moment this call started, counts. The ledger's state wins over the
234
+ * checkout's, because the store can cancel an order after checkout placed it.
235
+ */
236
+ function placedOrderOf(conversation, since) {
237
+ const last = conversation.last_checkout;
238
+ if (last?.order_id) {
239
+ // Two stores can use the same order number, so the store has to match as well.
240
+ const ledger = conversation.orders.find((order) => order.order_id === last.order_id && order.merchant === last.merchant);
241
+ return { orderId: last.order_id, merchant: last.merchant_name, status: ledger?.status ?? '' };
242
+ }
243
+ const stampedAt = last?.at ? Date.parse(last.at) : Number.NaN;
244
+ const from = Number.isFinite(stampedAt) ? stampedAt - (placementRunning(conversation) ? RESUME_SKEW_MS : 0) : since;
245
+ const current = conversation.orders.filter((order) => {
246
+ // An order from another store is another purchase, however close in time.
247
+ if (!order.order_id || (last && order.merchant !== last.merchant))
248
+ return false;
249
+ const placedAt = order.placed_at ? Date.parse(order.placed_at) : Number.NaN;
250
+ return Number.isFinite(placedAt) && placedAt >= from;
251
+ });
252
+ const newest = current.filter((order) => !STOPPED_ORDER.has(order.status)).at(-1) ?? current.at(-1);
253
+ return newest ? { orderId: newest.order_id, merchant: newest.merchant_name, status: newest.status } : null;
254
+ }
255
+ /** True when the last checkout is a placement Agentcard is still finishing. */
256
+ function placementRunning(conversation) {
257
+ const last = conversation.last_checkout;
258
+ return last?.code === 'in_progress' || last?.decline_code === 'in_progress' || last?.status === 'pending';
259
+ }
260
+ /**
261
+ * Why a finished checkout placed nothing, or null while it can still place: an order, a placement
262
+ * still running, a turn still running, or an approval waiting for the shopper are not refusals.
263
+ */
264
+ function checkoutRefusal(conversation) {
265
+ const last = conversation.last_checkout;
266
+ if (!last || last.order_id || conversation.turn_in_progress || placementRunning(conversation))
267
+ return null;
268
+ const code = last.decline_code ?? last.code;
269
+ if (!code || code === 'vault_approval_required')
270
+ return null;
271
+ return { code, merchant: last.merchant_name, message: last.message };
272
+ }
273
+ /**
274
+ * A buy tool for one shopper. With a company client, name the shopper with
275
+ * `externalUserId`; with a shopper's own `accessToken`, the token names them.
276
+ * Throws a TypeError for options that cannot work, before any call.
277
+ */
278
+ export function buyTool(options) {
279
+ const o = options ?? {};
280
+ if ((o.agentcard === undefined) === (o.accessToken === undefined)) {
281
+ throw new TypeError("Pass agentcard, a client built with your company credentials, or accessToken, the shopper's own token, and not both.");
282
+ }
283
+ if (o.agentcard !== undefined && typeof o.agentcard.buy !== 'function') {
284
+ throw new TypeError('Pass agentcard as an AgentcardClient.');
285
+ }
286
+ const client = o.agentcard ?? new AgentcardClient({ accessToken: o.accessToken, baseUrl: o.baseUrl, fetch: o.fetch });
287
+ const externalUserId = o.externalUserId;
288
+ if (client.company && (typeof externalUserId !== 'string' || externalUserId.trim() === '')) {
289
+ throw new TypeError('Pass externalUserId: your own id for this shopper. A company client buys for many shoppers, so each tool names its own.');
290
+ }
291
+ if (!client.company && externalUserId !== undefined) {
292
+ throw new TypeError("A shopper's own access token already names the shopper. Leave externalUserId out.");
293
+ }
294
+ if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
295
+ throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
296
+ }
297
+ const sendLink = o.sendLink;
298
+ const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
299
+ if (typeof wait !== 'function')
300
+ throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
301
+ const who = client.company ? { externalUserId } : {};
302
+ /** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
303
+ const read = async (conversationId) => {
304
+ try {
305
+ return await client.conversation(conversationId, who);
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ };
311
+ /** The order this purchase placed, or the reason it stopped when the ledger says it did. */
312
+ const placed = (base, order, conversation) => {
313
+ const conversationId = conversation.conversation_id || base.conversation_id;
314
+ if (!STOPPED_ORDER.has(order.status)) {
315
+ return {
316
+ ...base,
317
+ status: 'order_placed',
318
+ reply: `The ${order.merchant || 'store'} order went through, order number ${order.orderId}.`,
319
+ links: [],
320
+ next_action: null,
321
+ order_id: order.orderId,
322
+ conversation_id: conversationId,
323
+ };
324
+ }
325
+ return {
326
+ ...base,
327
+ status: 'declined',
328
+ reply: order.status === 'cancelled'
329
+ ? `${order.merchant || 'The store'} cancelled order ${order.orderId}.`
330
+ : `${NOT_PLACED} The ${order.merchant || 'store'} order ${order.orderId} stopped because its payment never confirmed.`,
331
+ links: [],
332
+ order_id: order.orderId,
333
+ decline_code: `order_${order.status}`,
334
+ next_action: null,
335
+ shopper_need_not_reply: false,
336
+ conversation_id: conversationId,
337
+ };
338
+ };
339
+ /** A checkout that finished without an order: the reason it gave, for the shopper to hear. */
340
+ const refusedCheckout = (base, refusal) => ({
341
+ ...base,
342
+ status: 'declined',
343
+ reply: refusal.message || `${NOT_PLACED} The ${refusal.merchant || 'store'} answered ${refusal.code}.`,
344
+ links: [],
345
+ decline_code: refusal.code,
346
+ next_action: null,
347
+ shopper_need_not_reply: false,
348
+ });
349
+ /**
350
+ * Placing after the tap: one 12-second pause, one read, then the order, the refusal the checkout
351
+ * finished with, or `placing`. `since` is when this call started, for `placedOrderOf`.
352
+ */
353
+ const awaitPlacement = async (base, conversationId, since) => {
354
+ await wait(PLACEMENT_WAIT_MS);
355
+ const conversation = await read(conversationId);
356
+ const order = conversation && placedOrderOf(conversation, since);
357
+ if (conversation && order)
358
+ return placed(base, order, conversation);
359
+ const refusal = conversation && checkoutRefusal(conversation);
360
+ if (refusal)
361
+ return refusedCheckout(base, refusal);
362
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
363
+ };
364
+ /**
365
+ * What a finished turn's checkout left besides an order, which `placedOrderOf`
366
+ * reads first: the refusal it ended with, or the approval it paused on.
367
+ * Agentcard clears the last checkout when a turn starts, so a checkout read
368
+ * here belongs to the turn that just ended.
369
+ */
370
+ const checkoutOf = (base, conversation) => {
371
+ const last = conversation.last_checkout;
372
+ // A checkout that found no card made an add-card link: the shopper gets it, as from a turn that ended the same way.
373
+ if (last?.decline_code === 'vault_unavailable' && last.add_card_url) {
374
+ return {
375
+ ...base,
376
+ status: 'needs_card',
377
+ reply: last.message || CARD_NEEDED,
378
+ links: [{ kind: 'vault', url: last.add_card_url, expiresAt: last.add_card_expires_at }],
379
+ decline_code: last.decline_code,
380
+ next_action: 'add_card',
381
+ shopper_need_not_reply: false,
382
+ envelope: conversation.raw,
383
+ };
384
+ }
385
+ if (last?.decline_code !== 'vault_approval_required' || !last.approval_url) {
386
+ // A checkout that ended refused keeps its cart on the table; the refusal is the answer, not the cart.
387
+ const refusal = checkoutRefusal(conversation);
388
+ return refusal ? refusedCheckout(base, refusal) : null;
389
+ }
390
+ const announced = last.placement_announced === true;
391
+ if (last.approval_in_progress === true)
392
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null, shopper_need_not_reply: announced };
393
+ return {
394
+ ...base,
395
+ status: 'approval_sent',
396
+ reply: APPROVAL_WAITING,
397
+ links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
398
+ approval_expires_at: last.approval_expires_at,
399
+ next_action: 'approve',
400
+ shopper_need_not_reply: announced,
401
+ envelope: conversation.raw,
402
+ };
403
+ };
404
+ /**
405
+ * A turn past Agentcard's budget: a read every 5 seconds for up to a minute,
406
+ * counted in elapsed time, so slow reads end the reading sooner. A turn that
407
+ * ends returns the order it placed, the refusal or the approval it ended on,
408
+ * the cart it left, or none. A new cart waits for the read that shows the
409
+ * turn ended: the running turn holds the conversation, and only a read that
410
+ * gets the conversation hands its one cart back as the cart `confirm: true`
411
+ * places. Carts at two stores return with no cart to confirm, so the shopper
412
+ * picks one first. A placement another attempt is still finishing keeps the
413
+ * reading going and ends as `placing`, never as the cart. A minute without
414
+ * any of them returns Agentcard's own answer, or what the last read showed
415
+ * while the turn still ran.
416
+ */
417
+ const awaitCart = async (base, conversationId, since) => {
418
+ const shown = new Set(base.hashes);
419
+ const deadline = Date.now() + TURN_READS * TURN_READ_EVERY_MS;
420
+ let placing = false;
421
+ // What the last read showed while the turn still ran, when it showed a new cart.
422
+ let early = null;
423
+ for (let i = 0; i < TURN_READS && Date.now() < deadline; i++) {
424
+ await wait(TURN_READ_EVERY_MS);
425
+ const conversation = await read(conversationId);
426
+ // A failed read loses nothing: the next one, while the minute lasts, can still find the cart.
427
+ if (!conversation)
428
+ continue;
429
+ placing = false;
430
+ early = null;
431
+ if (!conversation.turn_in_progress) {
432
+ const order = placedOrderOf(conversation, since);
433
+ if (order)
434
+ return placed(base, order, conversation);
435
+ const finished = checkoutOf(base, conversation);
436
+ if (finished)
437
+ return finished;
438
+ // The cart is being placed: its order is what the shopper waits for, not the cart again.
439
+ if (placementRunning(conversation)) {
440
+ placing = true;
441
+ continue;
442
+ }
443
+ }
444
+ const newest = conversation.carts.at(-1) ?? null;
445
+ if ((newest && !shown.has(newest.hash)) || !conversation.turn_in_progress) {
446
+ // A read hands its cart back, as the one `confirm: true` places, only when no turn holds the
447
+ // conversation and it shows one cart. With carts at two stores `confirm: true` still names the
448
+ // cart an earlier answer showed, so none is offered: the shopper's pick goes out as an ask.
449
+ const one = conversation.carts.length === 1 ? newest : null;
450
+ const answer = {
451
+ ...base,
452
+ reply: one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
453
+ cart: one,
454
+ carts: conversation.carts,
455
+ hashes: conversation.carts.map((c) => c.hash),
456
+ error_code: newest ? null : base.error_code,
457
+ // A read replaced the answer the step was named for, and names no step of its own: the reply asks.
458
+ next_action: null,
459
+ envelope: conversation.raw,
460
+ };
461
+ if (!conversation.turn_in_progress)
462
+ return answer;
463
+ // The running turn holds the conversation, so this read handed nothing back: read on until it ends.
464
+ early = answer;
465
+ }
466
+ }
467
+ if (placing)
468
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
469
+ // A minute that ends with the turn still running returns what the last read showed, a new cart as
470
+ // ready although no read handed it back: nothing better exists by then, and a confirm that misses it
471
+ // is refused, never placed on another cart.
472
+ return early ?? base;
473
+ };
474
+ const settleTurn = async (turn, startedAt) => {
475
+ const result = resultOfTurn(turn);
476
+ if (!turn.conversation_id)
477
+ return result;
478
+ // Checkout reports a placement another attempt is finishing as `in_progress`, under any status.
479
+ if (turn.decline_code === 'in_progress') {
480
+ return awaitPlacement({ ...result, shopper_need_not_reply: turn.placement_announced === true }, turn.conversation_id, startedAt);
481
+ }
482
+ if (result.status === 'needs_input' && turn.error_code === 'turn_timeout')
483
+ return awaitCart(result, turn.conversation_id, startedAt);
484
+ return result;
485
+ };
486
+ /**
487
+ * A refusal, settled: a shopper not connected yet gets the Vault link; a
488
+ * confirm refused because the order already took the cart, or because the
489
+ * placement is still running, reads the conversation for the order.
490
+ */
491
+ const settleRefusal = async (error, confirm, startedAt) => {
492
+ if (error instanceof AgentcardApiError && error.status === 403 && error.code === 'user_not_connected' && client.company) {
493
+ return needsCard(await client.mintVaultLink({ externalUserId: externalUserId }), error.body);
494
+ }
495
+ const refused = resultOfRefusal(error);
496
+ if (!refused)
497
+ throw error;
498
+ if (!confirm || !(error instanceof AgentcardApiError) || !error.conversationId || !AFTER_PLACEMENT.has(error.code))
499
+ return refused;
500
+ const conversationId = error.conversationId;
501
+ const conversation = await read(conversationId);
502
+ if (!conversation)
503
+ return refused;
504
+ // Only this purchase's order counts: the last checkout's, or one placed after it.
505
+ const order = placedOrderOf(conversation, startedAt);
506
+ if (order)
507
+ return placed(refused, order, conversation);
508
+ const placing = placementRunning(conversation);
509
+ if (!placing && !(error.code === 'turn_in_progress' && conversation.turn_in_progress))
510
+ return refused;
511
+ const settled = await awaitPlacement(refused, conversationId, startedAt);
512
+ // A running turn that placed nothing may be a search rather than a placement, so the shopper hears it is still working.
513
+ return settled.status === 'placing' && !placing ? refused : settled;
514
+ };
515
+ /** The store pages this tool sent last, with their conversation. */
516
+ let lastPages = null;
517
+ /**
518
+ * The store page of each product the model reads, sent once per page. A
519
+ * page the last batch in this conversation already sent stays with the
520
+ * shopper and goes out no second time. A page whose `sendLink` throws
521
+ * stays unsent, and the call goes on, so the next answer tries it again.
522
+ */
523
+ const deliverPages = async (result) => {
524
+ if (!sendLink)
525
+ return result;
526
+ const pages = new Map();
527
+ for (const product of shownProducts(result.products)) {
528
+ if (product.url && !pages.has(product.url))
529
+ pages.set(product.url, product);
530
+ }
531
+ // An answer with no store page leaves the last batch as it was.
532
+ if (pages.size === 0)
533
+ return result;
534
+ const before = lastPages?.conversationId === result.conversation_id ? lastPages.urls : new Set();
535
+ const held = new Set();
536
+ for (const [url, product] of pages) {
537
+ if (!before.has(url)) {
538
+ try {
539
+ await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, imageUrl: product.imageUrl, expiresAt: null });
540
+ }
541
+ catch {
542
+ continue;
543
+ }
544
+ }
545
+ held.add(url);
546
+ }
547
+ lastPages = { conversationId: result.conversation_id, urls: held };
548
+ return { ...result, products: result.products.map((product) => (product.url && held.has(product.url) ? { ...product, linkSent: true } : product)) };
549
+ };
550
+ /** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
551
+ let pagesInTurn = Promise.resolve();
552
+ const deliverPagesInTurn = (result) => {
553
+ const batch = pagesInTurn.then(() => deliverPages(result));
554
+ pagesInTurn = batch.catch(() => { });
555
+ return batch;
556
+ };
557
+ /** The Vault or approval link first, because the purchase waits on it, then the store pages. */
558
+ const deliver = async (result) => {
559
+ let delivered = result;
560
+ if (result.links.length > 0 && sendLink) {
561
+ for (const link of result.links)
562
+ await sendLink({ ...link });
563
+ delivered = { ...result, link_sent: true };
564
+ }
565
+ return deliverPagesInTurn(delivered);
566
+ };
567
+ const answer = async (raw) => {
568
+ const startedAt = Date.now();
569
+ const input = parseBuyToolInput(raw);
570
+ // A new order is never a confirm: a model that sets both drops the cart and places nothing.
571
+ const confirm = input.confirm && !input.new_order;
572
+ const ask = input.ask.trim();
573
+ if (!confirm && ask === '')
574
+ throw new TypeError("Put the shopper's words in ask.");
575
+ const body = {
576
+ resume: true,
577
+ ...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
578
+ ...(input.new_order ? { new_order: true } : {}),
579
+ ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
580
+ ...(client.company ? { external_user_id: externalUserId } : {}),
581
+ };
582
+ let turn = null;
583
+ let refusal;
584
+ try {
585
+ turn = await client.buy(body);
586
+ }
587
+ catch (error) {
588
+ refusal = error;
589
+ }
590
+ return deliver(withMessages(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt), turn));
591
+ };
592
+ /** Checks the model's input, with every link taken out of a refusal. */
593
+ const parse = (input) => {
594
+ try {
595
+ return parseBuyToolInput(input);
596
+ }
597
+ catch (error) {
598
+ throw forModelError(error);
599
+ }
600
+ };
601
+ /** Every call a framework makes: the model's input, checked, then the model's view of the answer as JSON text. */
602
+ const handle = async (args) => {
603
+ try {
604
+ return JSON.stringify(forModel(await answer(parseBuyToolInput(args))));
605
+ }
606
+ catch (error) {
607
+ throw forModelError(error);
608
+ }
609
+ };
610
+ const { name, description, input_schema } = buyToolDefinition();
611
+ const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel };
612
+ // JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
613
+ const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
614
+ return Object.defineProperties(tool, {
615
+ definition: unsent(buyToolDefinition()),
616
+ inputSchema: unsent(buyToolStandardSchema((error) => forModelError(error).message)),
617
+ openai: unsent(openAIBuyTool()),
618
+ });
619
+ }