@agent-cards/commerce-sdk 0.1.0 → 0.5.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/dist/buy-tool.js CHANGED
@@ -25,6 +25,8 @@
25
25
  import { AgentcardApiError, AgentcardClient, } from './client.js';
26
26
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
27
27
  import { forModel, forModelError, money, shownProducts } from './model-view.js';
28
+ import { toMessages } from './messages.js';
29
+ import { ackAt, isNewRequest, nextTemplate } from './ack.js';
28
30
  /** The pause before the one read that follows a placement Agentcard is still finishing. */
29
31
  const PLACEMENT_WAIT_MS = 12_000;
30
32
  /** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
@@ -40,6 +42,10 @@ const RESUME_SKEW_MS = 2 * 60_000;
40
42
  const STOPPED_ORDER = new Set(['failed', 'cancelled']);
41
43
  /** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
42
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;
43
49
  const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
44
50
  const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
45
51
  const STILL_PLACING = 'The shopper approved, and the order is still going through.';
@@ -51,6 +57,18 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
51
57
  const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
52
58
  const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
53
59
  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.';
60
+ 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.";
61
+ /**
62
+ * The pick as a later read of the conversation shows it: a pick the early answer found missing is in
63
+ * the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
64
+ * its store page, as its page. A pick already in the cart, or none, stays as it was.
65
+ */
66
+ function pickAfter(pick, carts) {
67
+ if (!pick || pick.in_cart)
68
+ return pick;
69
+ const held = carts.some((c) => c.items.some((line) => line.product_id === pick.product_id || line.url === pick.product_id));
70
+ return held ? { ...pick, in_cart: true } : pick;
71
+ }
54
72
  function hashesOf(cart, carts) {
55
73
  return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
56
74
  }
@@ -59,10 +77,12 @@ function productsOf(turn) {
59
77
  .filter((item) => item.name)
60
78
  .map((item) => ({
61
79
  id: item.id,
80
+ ref: item.ref,
62
81
  name: item.name,
63
82
  priceCents: item.priceCents,
64
83
  currency: item.currency,
65
84
  imageUrl: item.image_url,
85
+ ...(item.flight ? { flight: item.flight } : {}),
66
86
  store: item.store?.name ?? item.store?.id ?? turn.catalog?.merchant_name ?? null,
67
87
  url: item.url ?? null,
68
88
  linkSent: false,
@@ -79,6 +99,7 @@ function answerOf(turn) {
79
99
  return {
80
100
  status: 'needs_input',
81
101
  reply: turn.reply,
102
+ messages: [],
82
103
  cart: turn.cart,
83
104
  carts: turn.carts,
84
105
  products: productsOf(turn),
@@ -90,13 +111,61 @@ function answerOf(turn) {
90
111
  order_id: turn.order_id,
91
112
  decline_code: turn.decline_code,
92
113
  error_code: turn.error_code,
114
+ next_action: turn.next_action,
115
+ pick: turn.pick,
93
116
  hashes: hashesOf(turn.cart, turn.carts),
94
117
  envelope: turn.raw,
95
118
  };
96
119
  }
120
+ /**
121
+ * The answer that waits on the shopper's card, when the turn carries Agentcard's add-card link: a
122
+ * checkout refused on an empty vault (`vault_unavailable`), or the agent made the link while it
123
+ * asked. The status beside it does not decide: the link is the one way forward either way, and the
124
+ * reply has every link taken out. Null when the turn carries no link, or placed an order.
125
+ */
126
+ function needsCardOf(turn, answer) {
127
+ // Agentcard named another step first: the card waits until that one is done.
128
+ if (turn.next_action && turn.next_action !== 'add_card')
129
+ return null;
130
+ if (!turn.add_card_url || turn.status === 'order_placed' || turn.status === 'partially_placed' || turn.status === 'needs_approval')
131
+ return null;
132
+ const refused = turn.status === 'declined';
133
+ // A refusal with another code is about something other than a card, and keeps its own status.
134
+ if (refused && turn.decline_code !== 'vault_unavailable')
135
+ return null;
136
+ if (!refused && turn.decline_code && turn.decline_code !== 'vault_unavailable')
137
+ return null;
138
+ return { ...answer, status: 'needs_card', links: [...answer.links, { kind: 'vault', url: turn.add_card_url, expiresAt: turn.add_card_expires_at }] };
139
+ }
140
+ /**
141
+ * The answer with only the link of the step the shopper takes now, when Agentcard names one
142
+ * (`next_action`): the Vault link waits for `add_card`, the approval link for `approve`. A store login
143
+ * link and product pages are not steps, so they stay. An answer that names no step keeps every link.
144
+ */
145
+ function onlyNextStepLinks(result, next) {
146
+ if (!next)
147
+ return result;
148
+ const links = result.links.filter((link) => (link.kind === 'vault' ? next === 'add_card' : link.kind === 'approval' ? next === 'approve' : true));
149
+ return links.length === result.links.length ? result : { ...result, links };
150
+ }
151
+ /**
152
+ * The answer's `messages`, read once its `reply` is final: the messages Agentcard sent while the reply
153
+ * is still Agentcard's own, else the reply the tool wrote, each cut at its blank lines.
154
+ */
155
+ function withMessages(result, turn) {
156
+ const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
157
+ return { ...result, messages: sent.flatMap(toMessages) };
158
+ }
97
159
  /** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
98
160
  function resultOfTurn(turn) {
161
+ return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
162
+ }
163
+ /** A purchase answer, read for its status, with every link the answer carries. */
164
+ function resultOfStatus(turn) {
99
165
  const answer = answerOf(turn);
166
+ const card = needsCardOf(turn, answer);
167
+ if (card)
168
+ return card;
100
169
  switch (turn.status) {
101
170
  case 'order_placed':
102
171
  case 'partially_placed':
@@ -116,10 +185,6 @@ function resultOfTurn(turn) {
116
185
  shopper_need_not_reply: turn.placement_announced === true,
117
186
  };
118
187
  }
119
- // A shopper with no card gets the add-card link checkout made. Every other reason for this code is not about a card.
120
- if (turn.decline_code === 'vault_unavailable' && turn.add_card_url) {
121
- return { ...answer, status: 'needs_card', links: [...answer.links, { kind: 'vault', url: turn.add_card_url, expiresAt: turn.add_card_expires_at }] };
122
- }
123
188
  return { ...answer, status: 'declined' };
124
189
  default:
125
190
  return answer;
@@ -132,6 +197,7 @@ function resultOfRefusal(error) {
132
197
  const answer = (reply, carts) => ({
133
198
  status: 'needs_input',
134
199
  reply,
200
+ messages: [],
135
201
  cart: carts.at(-1) ?? null,
136
202
  carts,
137
203
  products: [],
@@ -143,6 +209,8 @@ function resultOfRefusal(error) {
143
209
  order_id: null,
144
210
  decline_code: null,
145
211
  error_code: error.code,
212
+ next_action: null,
213
+ pick: null,
146
214
  hashes: hashesOf(null, carts),
147
215
  envelope: error.body,
148
216
  });
@@ -154,6 +222,8 @@ function resultOfRefusal(error) {
154
222
  return answer(NOTHING_TO_CONFIRM, []);
155
223
  if (error.code === 'turn_in_progress')
156
224
  return answer(STILL_WORKING, []);
225
+ if (error.code === 'pick_not_found')
226
+ return answer(PICK_GONE, []);
157
227
  return null;
158
228
  }
159
229
  /** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
@@ -161,6 +231,7 @@ function needsCard(link, body) {
161
231
  return {
162
232
  status: 'needs_card',
163
233
  reply: CARD_NEEDED,
234
+ messages: [],
164
235
  cart: null,
165
236
  carts: [],
166
237
  products: [],
@@ -172,6 +243,8 @@ function needsCard(link, body) {
172
243
  order_id: null,
173
244
  decline_code: null,
174
245
  error_code: 'user_not_connected',
246
+ next_action: 'add_card',
247
+ pick: null,
175
248
  hashes: [],
176
249
  envelope: body,
177
250
  };
@@ -249,6 +322,63 @@ export function buyTool(options) {
249
322
  if (typeof wait !== 'function')
250
323
  throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
251
324
  const who = client.company ? { externalUserId } : {};
325
+ if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
326
+ throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
327
+ if (o.ack !== undefined && o.ack !== false && typeof o.ack !== 'function')
328
+ throw new TypeError('Pass ack as false, or as a function that writes the instant reply from the ask.');
329
+ const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
330
+ if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
331
+ throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
332
+ const onWorking = o.ack === false ? undefined : o.onWorking;
333
+ const writeAck = typeof o.ack === 'function' ? o.ack : null;
334
+ /** Calls running now, when the last one ended, whether this turn's instant reply is taken, and the template it used last. */
335
+ let running = 0;
336
+ let lastEndedAt = Number.NEGATIVE_INFINITY;
337
+ let turnAcked = false;
338
+ let lastTemplate = -1;
339
+ /** This turn's instant reply while it is being sent: every call of the turn waits for it before it hands over a link. */
340
+ let ackOut = null;
341
+ /**
342
+ * Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
343
+ * not gone out, and frees the turn's reply for a later call.
344
+ */
345
+ const holdAck = (ask) => {
346
+ if (!onWorking || turnAcked || !isNewRequest(ask))
347
+ return null;
348
+ turnAcked = true;
349
+ let held = true;
350
+ const timer = setTimeout(() => {
351
+ held = false;
352
+ let text;
353
+ if (writeAck) {
354
+ try {
355
+ text = writeAck(ask);
356
+ }
357
+ catch {
358
+ text = null;
359
+ }
360
+ }
361
+ else {
362
+ lastTemplate = nextTemplate(lastTemplate);
363
+ text = ackAt(lastTemplate, ask);
364
+ }
365
+ if (typeof text !== 'string' || text.trim() === '') {
366
+ turnAcked = false;
367
+ return;
368
+ }
369
+ const message = text.trim();
370
+ ackOut = Promise.resolve().then(() => onWorking(message)).catch(() => { });
371
+ }, ackDelayMs);
372
+ return {
373
+ cancel: () => {
374
+ if (!held)
375
+ return;
376
+ held = false;
377
+ clearTimeout(timer);
378
+ turnAcked = false;
379
+ },
380
+ };
381
+ };
252
382
  /** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
253
383
  const read = async (conversationId) => {
254
384
  try {
@@ -267,6 +397,7 @@ export function buyTool(options) {
267
397
  status: 'order_placed',
268
398
  reply: `The ${order.merchant || 'store'} order went through, order number ${order.orderId}.`,
269
399
  links: [],
400
+ next_action: null,
270
401
  order_id: order.orderId,
271
402
  conversation_id: conversationId,
272
403
  };
@@ -280,6 +411,7 @@ export function buyTool(options) {
280
411
  links: [],
281
412
  order_id: order.orderId,
282
413
  decline_code: `order_${order.status}`,
414
+ next_action: null,
283
415
  shopper_need_not_reply: false,
284
416
  conversation_id: conversationId,
285
417
  };
@@ -291,6 +423,7 @@ export function buyTool(options) {
291
423
  reply: refusal.message || `${NOT_PLACED} The ${refusal.merchant || 'store'} answered ${refusal.code}.`,
292
424
  links: [],
293
425
  decline_code: refusal.code,
426
+ next_action: null,
294
427
  shopper_need_not_reply: false,
295
428
  });
296
429
  /**
@@ -306,7 +439,7 @@ export function buyTool(options) {
306
439
  const refusal = conversation && checkoutRefusal(conversation);
307
440
  if (refusal)
308
441
  return refusedCheckout(base, refusal);
309
- return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
442
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
310
443
  };
311
444
  /**
312
445
  * What a finished turn's checkout left besides an order, which `placedOrderOf`
@@ -324,6 +457,7 @@ export function buyTool(options) {
324
457
  reply: last.message || CARD_NEEDED,
325
458
  links: [{ kind: 'vault', url: last.add_card_url, expiresAt: last.add_card_expires_at }],
326
459
  decline_code: last.decline_code,
460
+ next_action: 'add_card',
327
461
  shopper_need_not_reply: false,
328
462
  envelope: conversation.raw,
329
463
  };
@@ -335,13 +469,14 @@ export function buyTool(options) {
335
469
  }
336
470
  const announced = last.placement_announced === true;
337
471
  if (last.approval_in_progress === true)
338
- return { ...base, status: 'placing', reply: STILL_PLACING, links: [], shopper_need_not_reply: announced };
472
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null, shopper_need_not_reply: announced };
339
473
  return {
340
474
  ...base,
341
475
  status: 'approval_sent',
342
476
  reply: APPROVAL_WAITING,
343
477
  links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
344
478
  approval_expires_at: last.approval_expires_at,
479
+ next_action: 'approve',
345
480
  shopper_need_not_reply: announced,
346
481
  envelope: conversation.raw,
347
482
  };
@@ -399,6 +534,10 @@ export function buyTool(options) {
399
534
  carts: conversation.carts,
400
535
  hashes: conversation.carts.map((c) => c.hash),
401
536
  error_code: newest ? null : base.error_code,
537
+ // A read replaced the answer the step was named for, and names no step of its own: the reply asks.
538
+ next_action: null,
539
+ // The early answer judged the pick before the turn finished adding it.
540
+ pick: pickAfter(base.pick, conversation.carts),
402
541
  envelope: conversation.raw,
403
542
  };
404
543
  if (!conversation.turn_in_progress)
@@ -408,7 +547,7 @@ export function buyTool(options) {
408
547
  }
409
548
  }
410
549
  if (placing)
411
- return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
550
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
412
551
  // A minute that ends with the turn still running returns what the last read showed, a new cart as
413
552
  // ready although no read handed it back: nothing better exists by then, and a confirm that misses it
414
553
  // is refused, never placed on another cart.
@@ -479,7 +618,7 @@ export function buyTool(options) {
479
618
  for (const [url, product] of pages) {
480
619
  if (!before.has(url)) {
481
620
  try {
482
- await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, expiresAt: null });
621
+ await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, imageUrl: product.imageUrl, expiresAt: null });
483
622
  }
484
623
  catch {
485
624
  continue;
@@ -515,22 +654,43 @@ export function buyTool(options) {
515
654
  const ask = input.ask.trim();
516
655
  if (!confirm && ask === '')
517
656
  throw new TypeError("Put the shopper's words in ask.");
657
+ // A pick chooses from the products this conversation showed: never on a confirm, and never on a new order, which shows none yet.
658
+ const pick = !confirm && !input.new_order && input.product_id ? { product_id: input.product_id } : null;
518
659
  const body = {
519
660
  resume: true,
520
661
  ...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
521
662
  ...(input.new_order ? { new_order: true } : {}),
663
+ ...(pick ? { pick } : {}),
522
664
  ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
523
665
  ...(client.company ? { external_user_id: externalUserId } : {}),
524
666
  };
525
- let turn = null;
526
- let refusal;
667
+ // A call that starts while another runs, or right after one ended, is the model working through the same turn.
668
+ if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
669
+ turnAcked = false;
670
+ ackOut = null;
671
+ }
672
+ running += 1;
673
+ // A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
674
+ const ack = confirm || pick ? null : holdAck(ask);
527
675
  try {
528
- turn = await client.buy(body);
676
+ let turn = null;
677
+ let refusal;
678
+ try {
679
+ turn = await client.buy(body);
680
+ }
681
+ catch (error) {
682
+ refusal = error;
683
+ }
684
+ ack?.cancel();
685
+ const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
686
+ await ackOut;
687
+ return await deliver(withMessages(settled, turn));
529
688
  }
530
- catch (error) {
531
- refusal = error;
689
+ finally {
690
+ ack?.cancel();
691
+ running -= 1;
692
+ lastEndedAt = Date.now();
532
693
  }
533
- return deliver(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt));
534
694
  };
535
695
  /** Checks the model's input, with every link taken out of a refusal. */
536
696
  const parse = (input) => {
package/dist/client.d.ts CHANGED
@@ -51,6 +51,13 @@ export interface BuyRequest {
51
51
  resume?: true;
52
52
  /** Drop the cart on the table and start a new order. */
53
53
  new_order?: true;
54
+ /**
55
+ * The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
56
+ * Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
57
+ */
58
+ pick?: {
59
+ product_id: string;
60
+ };
54
61
  payment_source?: 'vault';
55
62
  delivery_address?: BuyDeliveryAddress;
56
63
  external_user_id?: string;
@@ -87,8 +94,22 @@ export interface BuyCart {
87
94
  hash: string;
88
95
  }
89
96
  /** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
97
+ /** Supplier facts only. Missing facts stay null; fare cabin does not establish segment cabins. */
98
+ export interface BuyFlightItinerary {
99
+ fareCabinClass?: string | null;
100
+ legs: Array<{
101
+ origin: string | null;
102
+ destination: string | null;
103
+ departureAt: string | null;
104
+ arrivalAt: string | null;
105
+ cabinClasses: Array<string | null>;
106
+ }>;
107
+ }
90
108
  export interface BuyCatalogItem {
109
+ flight?: BuyFlightItinerary;
91
110
  id: string;
111
+ /** The item's id with no link in it, which `pick.product_id` takes as it takes `id`; null from an older API. */
112
+ ref: string | null;
92
113
  name: string | null;
93
114
  priceCents: number | null;
94
115
  /** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
@@ -104,11 +125,25 @@ export interface BuyCatalog {
104
125
  merchant_name: string;
105
126
  items: BuyCatalogItem[];
106
127
  }
128
+ /** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
129
+ export interface BuyPick {
130
+ product_id: string;
131
+ ref: string | null;
132
+ in_cart: boolean;
133
+ }
107
134
  export interface BuyPaymentSource {
108
135
  source: string;
109
136
  brand: string | null;
110
137
  last4: string | null;
111
138
  }
139
+ /**
140
+ * The one thing the shopper does next, as Agentcard names it, in the order a purchase asks for them:
141
+ * `shipping_details` (a shipping address and phone), `guest_details` (a name, email and phone for a
142
+ * booking), `add_card` (a card in their Vault), `confirm` (their yes to the cart), `verification_code`
143
+ * (the code a booking texted them), then `approve` (the approval link). A value this version does
144
+ * not know reads as null.
145
+ */
146
+ export type BuyNextAction = 'shipping_details' | 'guest_details' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
112
147
  /** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
113
148
  export interface BuyTurn {
114
149
  conversation_id: string;
@@ -133,11 +168,15 @@ export interface BuyTurn {
133
168
  placement_announced: boolean | null;
134
169
  approval_expires_at: string | null;
135
170
  approval_in_progress: boolean | null;
171
+ /** The one step the shopper takes next, or null when nothing waits on them or Agentcard named none. */
172
+ next_action: BuyNextAction | null;
136
173
  /** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
137
174
  store_login_url: string | null;
138
175
  store_login_expires_at: string | null;
139
176
  /** The store that page signs in to. */
140
177
  store_login_merchant_name: string | null;
178
+ /** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
179
+ pick: BuyPick | null;
141
180
  /** The answer as Agentcard sent it. */
142
181
  raw: Record<string, unknown>;
143
182
  }
@@ -189,7 +228,7 @@ export interface VaultLink {
189
228
  /**
190
229
  * A refusal, or a failure to reach Agentcard. `code` is the API's own
191
230
  * (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
192
- * `user_id_mismatch`), one read from the shape of the answer
231
+ * `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
193
232
  * (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
194
233
  * `conversation_not_found`), or the client's (`unreachable`, `timeout`,
195
234
  * `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
package/dist/client.js CHANGED
@@ -16,10 +16,11 @@ export const BUY_TURN_TIMEOUT_MS = 130_000;
16
16
  const CALL_TIMEOUT_MS = 30_000;
17
17
  /** A token is renewed this long before it expires, so it never expires mid-call. */
18
18
  const RENEW_EARLY_MS = 60_000;
19
+ const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'add_card', 'confirm', 'verification_code', 'approve']);
19
20
  /**
20
21
  * A refusal, or a failure to reach Agentcard. `code` is the API's own
21
22
  * (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
22
- * `user_id_mismatch`), one read from the shape of the answer
23
+ * `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
23
24
  * (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
24
25
  * `conversation_not_found`), or the client's (`unreachable`, `timeout`,
25
26
  * `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
@@ -97,18 +98,36 @@ function cartOf(v) {
97
98
  function cartsOf(v) {
98
99
  return Array.isArray(v) ? v.map(cartOf).filter((c) => c !== null) : [];
99
100
  }
101
+ function flightOf(value) {
102
+ if (!isRecord(value) || !Array.isArray(value.legs))
103
+ return undefined;
104
+ return {
105
+ ...(Object.hasOwn(value, 'fareCabinClass') ? { fareCabinClass: str(value.fareCabinClass) } : {}),
106
+ legs: value.legs.map(value => {
107
+ const leg = isRecord(value) ? value : {};
108
+ return { origin: str(leg.origin), destination: str(leg.destination),
109
+ departureAt: str(leg.departureAt), arrivalAt: str(leg.arrivalAt),
110
+ cabinClasses: Array.isArray(leg.cabinClasses) ? leg.cabinClasses.map(str) : [] };
111
+ }),
112
+ };
113
+ }
100
114
  function catalogOf(v) {
101
115
  if (!isRecord(v))
102
116
  return null;
103
- const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => ({
104
- id: str(i.id) ?? '',
105
- name: str(i.name),
106
- priceCents: num(i.priceCents),
107
- currency: str(i.currency),
108
- image_url: str(i.image_url),
109
- store: storeOf(i.store),
110
- url: pageUrl(i.url),
111
- })) : [];
117
+ const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => {
118
+ const flight = flightOf(i.flight);
119
+ return {
120
+ id: str(i.id) ?? '',
121
+ ref: str(i.ref),
122
+ name: str(i.name),
123
+ priceCents: num(i.priceCents),
124
+ currency: str(i.currency),
125
+ image_url: str(i.image_url),
126
+ ...(flight ? { flight } : {}),
127
+ store: storeOf(i.store),
128
+ url: pageUrl(i.url),
129
+ };
130
+ }) : [];
112
131
  return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
113
132
  }
114
133
  function paymentSourceOf(v) {
@@ -142,6 +161,8 @@ export function turnOf(body) {
142
161
  store_login_url: str(body.store_login_url),
143
162
  store_login_expires_at: str(body.store_login_expires_at),
144
163
  store_login_merchant_name: str(body.store_login_merchant_name),
164
+ next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
165
+ pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
145
166
  raw: body,
146
167
  };
147
168
  }
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with three required fields. The delivery address and the
3
+ * JSON Schema with four required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown. The delivery address and the
4
5
  * shopper's id are construction options, never model inputs, so a model can
5
6
  * neither change where an order goes nor buy for someone else.
6
7
  */
7
8
  export declare const BUY_TOOL_NAME = "buy";
8
9
  /** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
9
10
  export declare const BUY_TOOL_DESCRIPTION: string;
10
- /** What the model sends: all three fields, every time. */
11
+ /**
12
+ * What the model sends: `ask`, `confirm` and `new_order` every time, and `product_id`, null unless the
13
+ * shopper picks a product from the last result. Your own code may leave `product_id` out.
14
+ */
11
15
  export interface BuyToolInput {
12
16
  ask: string;
13
17
  confirm: boolean;
14
18
  new_order: boolean;
19
+ /** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
20
+ product_id?: string | null;
15
21
  }
16
22
  /** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
17
23
  export type BuyToolInputSchema = {
@@ -29,6 +35,10 @@ export type BuyToolInputSchema = {
29
35
  type: 'boolean';
30
36
  description: string;
31
37
  };
38
+ product_id: {
39
+ type: ['string', 'null'];
40
+ description: string;
41
+ };
32
42
  };
33
43
  required: Array<keyof BuyToolInput>;
34
44
  additionalProperties: false;
@@ -42,8 +52,9 @@ export interface BuyToolDefinition {
42
52
  export declare function buyToolDefinition(): BuyToolDefinition;
43
53
  /**
44
54
  * The model's input, checked against the schema: an object, or its JSON text,
45
- * with `ask` a string and `confirm` and `new_order` booleans, and nothing
46
- * else. Throws a TypeError whose message tells the model what to send.
55
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
56
+ * string, null or left out, and nothing else. Throws a TypeError whose message
57
+ * tells the model what to send.
47
58
  */
48
59
  export declare function parseBuyToolInput(value: unknown): BuyToolInput;
49
60
  /** The buy tool in OpenAI's function-calling format, which the OpenAI SDK, OpenRouter's chat API and most other model APIs take. */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The buy tool as a model sees it: one name, one description, and a strict
3
- * JSON Schema with three required fields. The delivery address and the
3
+ * JSON Schema with four required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown. The delivery address and the
4
5
  * shopper's id are construction options, never model inputs, so a model can
5
6
  * neither change where an order goes nor buy for someone else.
6
7
  */
@@ -11,6 +12,7 @@ export const BUY_TOOL_DESCRIPTION = [
11
12
  "Put the shopper's own words in `ask`.",
12
13
  'Tell the shopper what `reply` says, in your own voice.',
13
14
  'Show the shopper the cart before you confirm it.',
15
+ 'Ask the shopper for exactly one thing per message: the step `next_action` names, even when `reply` lists more. Never mention a later step, and never ask for the next one until the shopper has done this one.',
14
16
  'Set `confirm` to true only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
15
17
  'Never write a link.',
16
18
  'Each link reaches the shopper on its own.',
@@ -21,6 +23,8 @@ export const BUY_TOOL_DESCRIPTION = [
21
23
  'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
22
24
  "After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
23
25
  'Set `new_order` to true only when the shopper drops the cart.',
26
+ "When the shopper picks one of the `products` in your last result, by its name, its place in the list or a tap that names it, set `product_id` to that product's `product_id`, so Agentcard adds exactly that product; otherwise set it to null.",
27
+ 'When `pick.in_cart` is false, the product the shopper picked is not in the cart: tell the shopper what `reply` says, and never ask them to confirm the cart.',
24
28
  ].join(' ');
25
29
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
26
30
  export function buyToolDefinition() {
@@ -36,17 +40,22 @@ export function buyToolDefinition() {
36
40
  description: 'True only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again. False otherwise.',
37
41
  },
38
42
  new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
43
+ product_id: {
44
+ type: ['string', 'null'],
45
+ description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
46
+ },
39
47
  },
40
- required: ['ask', 'confirm', 'new_order'],
48
+ required: ['ask', 'confirm', 'new_order', 'product_id'],
41
49
  additionalProperties: false,
42
50
  },
43
51
  };
44
52
  }
45
- const FIELDS = new Set(['ask', 'confirm', 'new_order']);
53
+ const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
46
54
  /**
47
55
  * The model's input, checked against the schema: an object, or its JSON text,
48
- * with `ask` a string and `confirm` and `new_order` booleans, and nothing
49
- * else. Throws a TypeError whose message tells the model what to send.
56
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
57
+ * string, null or left out, and nothing else. Throws a TypeError whose message
58
+ * tells the model what to send.
50
59
  */
51
60
  export function parseBuyToolInput(value) {
52
61
  let input = value;
@@ -64,14 +73,18 @@ export function parseBuyToolInput(value) {
64
73
  const fields = input;
65
74
  const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
66
75
  if (extra !== undefined)
67
- throw new TypeError(`The buy tool takes only ask, confirm and new_order, and ${extra} is not one of them.`);
76
+ throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
68
77
  if (typeof fields.ask !== 'string')
69
78
  throw new TypeError("Set ask to the shopper's words, as a string.");
70
79
  if (typeof fields.confirm !== 'boolean')
71
80
  throw new TypeError('Set confirm to true or false.');
72
81
  if (typeof fields.new_order !== 'boolean')
73
82
  throw new TypeError('Set new_order to true or false.');
74
- return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order };
83
+ const productId = fields.product_id;
84
+ if (productId !== undefined && productId !== null && typeof productId !== 'string') {
85
+ throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
86
+ }
87
+ return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
75
88
  }
76
89
  /** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
77
90
  export function openAIBuyTool() {