@agent-cards/commerce-sdk 0.1.0 → 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.
package/LICENSE CHANGED
@@ -35,4 +35,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
35
  IMPLIED. IN NO EVENT SHALL AGENTCARD CORPORATION BE LIABLE FOR ANY CLAIM,
36
36
  DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
37
37
 
38
- Questions: legal@agentcard.sh
38
+ Questions: legal@agentcard.com
package/README.md CHANGED
@@ -101,10 +101,35 @@ console.log(await result.getText());
101
101
 
102
102
  A purchase needs a link at three moments: the shopper approves the purchase on an approval page, a new shopper adds a card on a Vault page, and a shopper signs in to a store's own account on its login page before Agentcard can buy there. Each link's `kind` says which: `approval`, `vault` or `store_login`, and a `store_login` link names the store in `store`. The tool hands each link to `sendLink` and waits for it, so the shopper holds the link before the model answers. The model reads `link_sent: true` and never the link itself, because a tool runner puts each answer straight into the model's context, and a model that holds a link writes it into the chat.
103
103
 
104
- The tool also hands `sendLink` a `product` link for each product a search finds, up to twenty per answer. The link carries the product's page at its store in `url`, the product's name in `title`, its price in `price`, such as `$12.99`, and its id in `productId`. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. The model reads `product_links_sent` and never a URL, so a shopper who asks for links hears that the links are there. When the next answer in the conversation shows the same products, the tool sends none of their links again. A product link your `sendLink` throws on counts as not sent, and the call goes on.
104
+ The tool also hands `sendLink` a `product` link for each product a search finds, up to three per answer. The link carries the product's page at its store in `url`, the product's name in `title`, its price in `price`, such as `$12.99`, its id in `productId`, and its picture in `imageUrl`, or null when the store sent none. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. The model reads `product_links_sent` and never a URL, so a shopper who asks for links hears that the links are there. When the next answer in the conversation shows the same products, the tool sends none of their links again. A product link your `sendLink` throws on counts as not sent, and the call goes on.
105
105
 
106
106
  Without `sendLink`, `result.links` still carries each Vault, approval and store login link for your code, each product in `result.products` carries its `url`, and the model still never reads one.
107
107
 
108
+ ## Send each paragraph as its own message
109
+
110
+ A chat app reads best when each paragraph arrives as its own message. `toMessages` cuts text at its blank lines, trims each message and leaves out the empty ones. A list whose items blank lines separate stays one message, and so does a fenced code block:
111
+
112
+ ```ts
113
+ import { toMessages } from '@agent-cards/commerce-sdk';
114
+
115
+ for (const text of toMessages(reply)) await textShopper(shopper.id, text);
116
+ ```
117
+
118
+ `result.messages` from `buy.answer` holds Agentcard's own answer cut the same way, links included, so your code sends it without cutting it again.
119
+
120
+ For a reply your model streams, `messageStream` hands over each paragraph the moment the blank line that ends it arrives, however the stream cuts the text. Push each piece of text, then call `end` for the last paragraph:
121
+
122
+ ```ts
123
+ import { messageStream } from '@agent-cards/commerce-sdk';
124
+
125
+ const paragraphs = messageStream({ onMessage: (text) => textShopper(shopper.id, text) });
126
+ stream.on('text', (delta) => paragraphs.push(delta));
127
+ await stream.done();
128
+ await paragraphs.end();
129
+ ```
130
+
131
+ `stream` stands for your model's streamed answer. When `onMessage` returns a promise, the stream waits for it before it sends the next paragraph, so the shopper reads them in order, and `end` resolves once every paragraph went out. After a send fails, no later paragraph goes out, and `end` rejects with that error. A paragraph inside a list waits for the first characters of the next line, which say whether the list goes on. Start a new `messageStream` for each model message, so one message never runs into the next.
132
+
108
133
  ## Sign up a new shopper
109
134
 
110
135
  A shopper who has not connected to your company yet gets a Vault link on their first call. The tool asks Agentcard for the link and hands it to `sendLink`, and the model reads `needs_card`. The shopper connects and adds a card on the Vault page, and the next call buys. Agentcard returns the same link while it has time left, so a shopper who asks twice gets one link.
@@ -22,7 +22,7 @@
22
22
  * A turn that outlived Agentcard's budget gets a read every 5 seconds for up
23
23
  * to a minute, until the turn ends.
24
24
  */
25
- import { AgentcardClient, type BuyCart, type BuyDeliveryAddress, type FetchLike } from './client.js';
25
+ import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type FetchLike } from './client.js';
26
26
  import { type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolStandardSchema, type OpenAIBuyTool } from './definition.js';
27
27
  import { type BuyToolModelView } from './model-view.js';
28
28
  /**
@@ -40,7 +40,7 @@ export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'pl
40
40
  * card. `approval`: the page where they approve this purchase. `store_login`:
41
41
  * the page where they sign in to the store named in `store`, before Agentcard
42
42
  * can buy there. `product`: a product's page at its store, with its `title`,
43
- * `price` and `productId`.
43
+ * `price`, `productId` and `imageUrl`.
44
44
  */
45
45
  export interface BuyToolLink {
46
46
  kind: 'vault' | 'approval' | 'store_login' | 'product';
@@ -55,9 +55,12 @@ export interface BuyToolLink {
55
55
  price?: string | null;
56
56
  /** On a product link: Agentcard's id for the product. */
57
57
  productId?: string;
58
+ /** On a product link: the product's picture, or null when Agentcard sent none. Show it beside the title. */
59
+ imageUrl?: string | null;
58
60
  }
59
61
  /** A product the last search found. */
60
62
  export interface BuyToolProduct {
63
+ flight?: BuyFlightItinerary;
61
64
  id: string;
62
65
  name: string;
63
66
  /** The price in the minor unit of `currency`. */
@@ -80,6 +83,12 @@ export interface BuyToolResult {
80
83
  status: BuyToolStatus;
81
84
  /** Agentcard's answer for the shopper, links included. */
82
85
  reply: string;
86
+ /**
87
+ * The same answer as the separate messages a shopper reads one at a time, links included: each
88
+ * message Agentcard sent (the purchase answer's `messages`), cut at its blank lines by `toMessages`.
89
+ * An answer the tool wrote itself, such as `placing`, is `reply` cut the same way.
90
+ */
91
+ messages: string[];
83
92
  /** The cart on the table, or null. */
84
93
  cart: BuyCart | null;
85
94
  /** Every cart on the table, oldest first. */
@@ -97,6 +106,8 @@ export interface BuyToolResult {
97
106
  order_id: string | null;
98
107
  decline_code: string | null;
99
108
  error_code: string | null;
109
+ /** The one step the shopper takes next, as Agentcard named it, or null. Only that step's link is in `links`. */
110
+ next_action: BuyNextAction | null;
100
111
  /** Every cart hash in this answer. */
101
112
  hashes: string[];
102
113
  /** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
package/dist/buy-tool.js CHANGED
@@ -25,6 +25,7 @@
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';
28
29
  /** The pause before the one read that follows a placement Agentcard is still finishing. */
29
30
  const PLACEMENT_WAIT_MS = 12_000;
30
31
  /** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
@@ -63,6 +64,7 @@ function productsOf(turn) {
63
64
  priceCents: item.priceCents,
64
65
  currency: item.currency,
65
66
  imageUrl: item.image_url,
67
+ ...(item.flight ? { flight: item.flight } : {}),
66
68
  store: item.store?.name ?? item.store?.id ?? turn.catalog?.merchant_name ?? null,
67
69
  url: item.url ?? null,
68
70
  linkSent: false,
@@ -79,6 +81,7 @@ function answerOf(turn) {
79
81
  return {
80
82
  status: 'needs_input',
81
83
  reply: turn.reply,
84
+ messages: [],
82
85
  cart: turn.cart,
83
86
  carts: turn.carts,
84
87
  products: productsOf(turn),
@@ -90,13 +93,60 @@ function answerOf(turn) {
90
93
  order_id: turn.order_id,
91
94
  decline_code: turn.decline_code,
92
95
  error_code: turn.error_code,
96
+ next_action: turn.next_action,
93
97
  hashes: hashesOf(turn.cart, turn.carts),
94
98
  envelope: turn.raw,
95
99
  };
96
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
+ }
97
140
  /** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
98
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) {
99
146
  const answer = answerOf(turn);
147
+ const card = needsCardOf(turn, answer);
148
+ if (card)
149
+ return card;
100
150
  switch (turn.status) {
101
151
  case 'order_placed':
102
152
  case 'partially_placed':
@@ -116,10 +166,6 @@ function resultOfTurn(turn) {
116
166
  shopper_need_not_reply: turn.placement_announced === true,
117
167
  };
118
168
  }
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
169
  return { ...answer, status: 'declined' };
124
170
  default:
125
171
  return answer;
@@ -132,6 +178,7 @@ function resultOfRefusal(error) {
132
178
  const answer = (reply, carts) => ({
133
179
  status: 'needs_input',
134
180
  reply,
181
+ messages: [],
135
182
  cart: carts.at(-1) ?? null,
136
183
  carts,
137
184
  products: [],
@@ -143,6 +190,7 @@ function resultOfRefusal(error) {
143
190
  order_id: null,
144
191
  decline_code: null,
145
192
  error_code: error.code,
193
+ next_action: null,
146
194
  hashes: hashesOf(null, carts),
147
195
  envelope: error.body,
148
196
  });
@@ -161,6 +209,7 @@ function needsCard(link, body) {
161
209
  return {
162
210
  status: 'needs_card',
163
211
  reply: CARD_NEEDED,
212
+ messages: [],
164
213
  cart: null,
165
214
  carts: [],
166
215
  products: [],
@@ -172,6 +221,7 @@ function needsCard(link, body) {
172
221
  order_id: null,
173
222
  decline_code: null,
174
223
  error_code: 'user_not_connected',
224
+ next_action: 'add_card',
175
225
  hashes: [],
176
226
  envelope: body,
177
227
  };
@@ -267,6 +317,7 @@ export function buyTool(options) {
267
317
  status: 'order_placed',
268
318
  reply: `The ${order.merchant || 'store'} order went through, order number ${order.orderId}.`,
269
319
  links: [],
320
+ next_action: null,
270
321
  order_id: order.orderId,
271
322
  conversation_id: conversationId,
272
323
  };
@@ -280,6 +331,7 @@ export function buyTool(options) {
280
331
  links: [],
281
332
  order_id: order.orderId,
282
333
  decline_code: `order_${order.status}`,
334
+ next_action: null,
283
335
  shopper_need_not_reply: false,
284
336
  conversation_id: conversationId,
285
337
  };
@@ -291,6 +343,7 @@ export function buyTool(options) {
291
343
  reply: refusal.message || `${NOT_PLACED} The ${refusal.merchant || 'store'} answered ${refusal.code}.`,
292
344
  links: [],
293
345
  decline_code: refusal.code,
346
+ next_action: null,
294
347
  shopper_need_not_reply: false,
295
348
  });
296
349
  /**
@@ -306,7 +359,7 @@ export function buyTool(options) {
306
359
  const refusal = conversation && checkoutRefusal(conversation);
307
360
  if (refusal)
308
361
  return refusedCheckout(base, refusal);
309
- return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
362
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
310
363
  };
311
364
  /**
312
365
  * What a finished turn's checkout left besides an order, which `placedOrderOf`
@@ -324,6 +377,7 @@ export function buyTool(options) {
324
377
  reply: last.message || CARD_NEEDED,
325
378
  links: [{ kind: 'vault', url: last.add_card_url, expiresAt: last.add_card_expires_at }],
326
379
  decline_code: last.decline_code,
380
+ next_action: 'add_card',
327
381
  shopper_need_not_reply: false,
328
382
  envelope: conversation.raw,
329
383
  };
@@ -335,13 +389,14 @@ export function buyTool(options) {
335
389
  }
336
390
  const announced = last.placement_announced === true;
337
391
  if (last.approval_in_progress === true)
338
- return { ...base, status: 'placing', reply: STILL_PLACING, links: [], shopper_need_not_reply: announced };
392
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null, shopper_need_not_reply: announced };
339
393
  return {
340
394
  ...base,
341
395
  status: 'approval_sent',
342
396
  reply: APPROVAL_WAITING,
343
397
  links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
344
398
  approval_expires_at: last.approval_expires_at,
399
+ next_action: 'approve',
345
400
  shopper_need_not_reply: announced,
346
401
  envelope: conversation.raw,
347
402
  };
@@ -399,6 +454,8 @@ export function buyTool(options) {
399
454
  carts: conversation.carts,
400
455
  hashes: conversation.carts.map((c) => c.hash),
401
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,
402
459
  envelope: conversation.raw,
403
460
  };
404
461
  if (!conversation.turn_in_progress)
@@ -408,7 +465,7 @@ export function buyTool(options) {
408
465
  }
409
466
  }
410
467
  if (placing)
411
- return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
468
+ return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
412
469
  // A minute that ends with the turn still running returns what the last read showed, a new cart as
413
470
  // ready although no read handed it back: nothing better exists by then, and a confirm that misses it
414
471
  // is refused, never placed on another cart.
@@ -479,7 +536,7 @@ export function buyTool(options) {
479
536
  for (const [url, product] of pages) {
480
537
  if (!before.has(url)) {
481
538
  try {
482
- await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, expiresAt: null });
539
+ await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, imageUrl: product.imageUrl, expiresAt: null });
483
540
  }
484
541
  catch {
485
542
  continue;
@@ -530,7 +587,7 @@ export function buyTool(options) {
530
587
  catch (error) {
531
588
  refusal = error;
532
589
  }
533
- return deliver(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt));
590
+ return deliver(withMessages(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt), turn));
534
591
  };
535
592
  /** Checks the model's input, with every link taken out of a refusal. */
536
593
  const parse = (input) => {
package/dist/client.d.ts CHANGED
@@ -87,7 +87,19 @@ export interface BuyCart {
87
87
  hash: string;
88
88
  }
89
89
  /** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
90
+ /** Supplier facts only. Missing facts stay null; fare cabin does not establish segment cabins. */
91
+ export interface BuyFlightItinerary {
92
+ fareCabinClass?: string | null;
93
+ legs: Array<{
94
+ origin: string | null;
95
+ destination: string | null;
96
+ departureAt: string | null;
97
+ arrivalAt: string | null;
98
+ cabinClasses: Array<string | null>;
99
+ }>;
100
+ }
90
101
  export interface BuyCatalogItem {
102
+ flight?: BuyFlightItinerary;
91
103
  id: string;
92
104
  name: string | null;
93
105
  priceCents: number | null;
@@ -109,6 +121,14 @@ export interface BuyPaymentSource {
109
121
  brand: string | null;
110
122
  last4: string | null;
111
123
  }
124
+ /**
125
+ * The one thing the shopper does next, as Agentcard names it, in the order a purchase asks for them:
126
+ * `shipping_details` (a shipping address and phone), `guest_details` (a name, email and phone for a
127
+ * booking), `add_card` (a card in their Vault), `confirm` (their yes to the cart), `verification_code`
128
+ * (the code a booking texted them), then `approve` (the approval link). A value this version does
129
+ * not know reads as null.
130
+ */
131
+ export type BuyNextAction = 'shipping_details' | 'guest_details' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
112
132
  /** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
113
133
  export interface BuyTurn {
114
134
  conversation_id: string;
@@ -133,6 +153,8 @@ export interface BuyTurn {
133
153
  placement_announced: boolean | null;
134
154
  approval_expires_at: string | null;
135
155
  approval_in_progress: boolean | null;
156
+ /** The one step the shopper takes next, or null when nothing waits on them or Agentcard named none. */
157
+ next_action: BuyNextAction | null;
136
158
  /** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
137
159
  store_login_url: string | null;
138
160
  store_login_expires_at: string | null;
package/dist/client.js CHANGED
@@ -16,6 +16,7 @@ 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`,
@@ -97,18 +98,35 @@ 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
+ name: str(i.name),
122
+ priceCents: num(i.priceCents),
123
+ currency: str(i.currency),
124
+ image_url: str(i.image_url),
125
+ ...(flight ? { flight } : {}),
126
+ store: storeOf(i.store),
127
+ url: pageUrl(i.url),
128
+ };
129
+ }) : [];
112
130
  return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
113
131
  }
114
132
  function paymentSourceOf(v) {
@@ -142,6 +160,7 @@ export function turnOf(body) {
142
160
  store_login_url: str(body.store_login_url),
143
161
  store_login_expires_at: str(body.store_login_expires_at),
144
162
  store_login_merchant_name: str(body.store_login_merchant_name),
163
+ next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
145
164
  raw: body,
146
165
  };
147
166
  }
@@ -11,6 +11,7 @@ export const BUY_TOOL_DESCRIPTION = [
11
11
  "Put the shopper's own words in `ask`.",
12
12
  'Tell the shopper what `reply` says, in your own voice.',
13
13
  'Show the shopper the cart before you confirm it.',
14
+ '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
15
  '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
16
  'Never write a link.',
16
17
  'Each link reaches the shopper on its own.',
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
3
  export { purchaseAgentWebhook, type PurchaseAgentWebhookOptions, type WebhookRequest, type WebhookResponse, } from './handler.js';
4
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, } from './client.js';
4
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, } from './client.js';
5
5
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
6
+ export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
6
7
  export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
7
8
  export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
8
9
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { readPurchaseMessage, } from './message.js';
3
3
  export { purchaseAgentWebhook, } from './handler.js';
4
4
  export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
5
5
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
6
+ export { toMessages, messageStream, } from './messages.js';
6
7
  export { buyTool, } from './buy-tool.js';
7
8
  export { forModel, stripLinks, } from './model-view.js';
8
9
  export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
3
+ * own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
4
+ * paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
5
+ * items a blank line separates.
6
+ */
7
+ /**
8
+ * The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
9
+ * out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
10
+ * lines it holds, and so does a list whose items blank lines separate.
11
+ *
12
+ * ```ts
13
+ * toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
14
+ * ```
15
+ */
16
+ export declare function toMessages(text: string): string[];
17
+ /** What `messageStream` hands back: push each piece of text as it arrives, then end once it stops. */
18
+ export interface MessageStream {
19
+ /** Adds the next piece of text. Each message the piece completes goes to `onMessage` in order, after the one before it settled. */
20
+ push(delta: string): void;
21
+ /**
22
+ * The text is whole: the last message goes to `onMessage`. Resolves once every `onMessage` call
23
+ * settled, and rejects with the first error one threw or rejected with. A push after `end` throws.
24
+ */
25
+ end(): Promise<void>;
26
+ }
27
+ export interface MessageStreamOptions {
28
+ /**
29
+ * Hears each message, once and in order, as soon as the text after it ends it. A returned promise
30
+ * is awaited before the next message goes out, so an asynchronous send never delivers out of order.
31
+ * After a send fails, no later message is sent, and `end` rejects with the failure.
32
+ */
33
+ onMessage: (message: string) => void | Promise<void>;
34
+ }
35
+ /**
36
+ * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
37
+ * `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
38
+ * paragraph inside a list waits for the first characters of the line after the blank, which say
39
+ * whether the list goes on. `end` hands over the last message and waits for every send.
40
+ *
41
+ * ```ts
42
+ * const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
43
+ * stream.on('text', (delta) => paragraphs.push(delta));
44
+ * await stream.done();
45
+ * await paragraphs.end();
46
+ * ```
47
+ */
48
+ export declare function messageStream(options: MessageStreamOptions): MessageStream;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
3
+ * own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
4
+ * paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
5
+ * items a blank line separates.
6
+ */
7
+ /** A line holding nothing but spaces or tabs. */
8
+ const BLANK = /^[ \t]*$/;
9
+ /** A list item: `-`, `*` or `+`, or a number followed by `.` or `)`, then a space or the line's end. */
10
+ const LIST_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
11
+ /** A list item whose marker a space already follows, the only kind a line still being written is sure to stay. */
12
+ const LIST_ITEM_SPACED = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/;
13
+ /** A line indented under a list item: two spaces or a tab, then text. */
14
+ const INDENTED = /^(?: {2,}|\t)\S/;
15
+ /** The start of a line that can still grow into a list item or an indented line once more text arrives. */
16
+ const MAYBE_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)]?)?$|^[ \t]*$/;
17
+ /** A code fence: three or more backticks or tildes, indented by at most three spaces. */
18
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
19
+ /** How the line after a blank line inside a list reads: it continues the list, it ends it, or it is too short to tell. */
20
+ function afterListBlank(line, complete) {
21
+ // `1.` alone is an item once the line ends there, and the start of `1.5 cups` while it is still arriving.
22
+ if ((complete ? LIST_ITEM : LIST_ITEM_SPACED).test(line) || INDENTED.test(line))
23
+ return 'continues';
24
+ if (!complete && MAYBE_ITEM.test(line))
25
+ return 'unknown';
26
+ return 'ends';
27
+ }
28
+ /** True when `line` closes the fence `open` opened: the same character, at least as many times, and nothing after. */
29
+ function closesFence(line, open) {
30
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
31
+ return !!match && match[1][0] === open[0] && match[1].length >= open.length;
32
+ }
33
+ /**
34
+ * Reads text line by line and closes each message once nothing later can change it. It keeps only
35
+ * the message being built, so text fed in pieces costs what the text costs once.
36
+ */
37
+ class Scanner {
38
+ onClose;
39
+ current = [];
40
+ fence = null;
41
+ inList = false;
42
+ blankInList = false;
43
+ constructor(onClose) {
44
+ this.onClose = onClose;
45
+ }
46
+ close() {
47
+ const message = this.current.join('\n').trim();
48
+ this.current = [];
49
+ this.inList = false;
50
+ this.blankInList = false;
51
+ if (message)
52
+ this.onClose(message);
53
+ }
54
+ /** One whole line. */
55
+ line(line) {
56
+ if (this.fence !== null) {
57
+ this.current.push(line);
58
+ if (closesFence(line, this.fence))
59
+ this.fence = null;
60
+ return;
61
+ }
62
+ if (BLANK.test(line)) {
63
+ if (this.current.length === 0)
64
+ return;
65
+ // A blank line ends the paragraph, unless it sits between two items of one list.
66
+ if (this.inList)
67
+ this.blankInList = true;
68
+ else
69
+ this.close();
70
+ return;
71
+ }
72
+ if (this.blankInList) {
73
+ if (afterListBlank(line, true) === 'ends')
74
+ this.close();
75
+ else
76
+ this.current.push('');
77
+ this.blankInList = false;
78
+ }
79
+ this.current.push(line);
80
+ if (LIST_ITEM.test(line))
81
+ this.inList = true;
82
+ else if (this.current.length === 1)
83
+ this.inList = false;
84
+ const open = FENCE.exec(line);
85
+ if (open)
86
+ this.fence = open[1];
87
+ }
88
+ /**
89
+ * The start of a line still being written. After a blank line inside a list, its first characters
90
+ * can already say the list ended, and the list goes out as soon as they do. Only the start counts:
91
+ * no marker, indent or number runs past 16 characters.
92
+ */
93
+ partial(start) {
94
+ if (this.fence === null && this.blankInList && afterListBlank(start.slice(0, 16), false) === 'ends')
95
+ this.close();
96
+ }
97
+ /** The text is whole: the message being built closes. */
98
+ end() {
99
+ this.close();
100
+ }
101
+ }
102
+ /**
103
+ * The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
104
+ * out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
105
+ * lines it holds, and so does a list whose items blank lines separate.
106
+ *
107
+ * ```ts
108
+ * toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
109
+ * ```
110
+ */
111
+ export function toMessages(text) {
112
+ const messages = [];
113
+ const scanner = new Scanner((message) => messages.push(message));
114
+ for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n'))
115
+ scanner.line(line);
116
+ scanner.end();
117
+ return messages;
118
+ }
119
+ /**
120
+ * Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
121
+ * `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
122
+ * paragraph inside a list waits for the first characters of the line after the blank, which say
123
+ * whether the list goes on. `end` hands over the last message and waits for every send.
124
+ *
125
+ * ```ts
126
+ * const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
127
+ * stream.on('text', (delta) => paragraphs.push(delta));
128
+ * await stream.done();
129
+ * await paragraphs.end();
130
+ * ```
131
+ */
132
+ export function messageStream(options) {
133
+ const onMessage = options?.onMessage;
134
+ if (typeof onMessage !== 'function')
135
+ throw new TypeError('Pass onMessage as a function that takes each message.');
136
+ // The line still being written. A trailing \r waits here too, in case its \n is in the next piece.
137
+ let open = '';
138
+ let ended = false;
139
+ let failure = null;
140
+ // Messages waiting for the send before them. A send that returns no promise goes out at once.
141
+ const queue = [];
142
+ let running = null;
143
+ const drain = () => {
144
+ while (queue.length > 0 && !failure) {
145
+ const message = queue.shift();
146
+ let sent;
147
+ try {
148
+ sent = onMessage(message);
149
+ }
150
+ catch (error) {
151
+ failure = { error };
152
+ break;
153
+ }
154
+ if (sent && typeof sent.then === 'function') {
155
+ return Promise.resolve(sent).then(() => drain() ?? undefined, (error) => {
156
+ failure = { error };
157
+ });
158
+ }
159
+ }
160
+ return null;
161
+ };
162
+ const kick = () => {
163
+ if (running)
164
+ return;
165
+ const pending = drain();
166
+ if (pending) {
167
+ running = pending.then(() => {
168
+ running = null;
169
+ kick();
170
+ });
171
+ }
172
+ };
173
+ const scanner = new Scanner((message) => {
174
+ queue.push(message);
175
+ kick();
176
+ });
177
+ return {
178
+ push(delta) {
179
+ if (ended)
180
+ throw new Error('This message stream has ended. Start a new one for the next text.');
181
+ if (typeof delta !== 'string' || delta === '')
182
+ return;
183
+ const text = open + delta;
184
+ const cut = text.endsWith('\r') ? text.length - 1 : text.length;
185
+ const lines = text.slice(0, cut).replace(/\r\n?/g, '\n').split('\n');
186
+ open = (lines.pop() ?? '') + text.slice(cut);
187
+ for (const line of lines)
188
+ scanner.line(line);
189
+ // A held \r is a line ending still arriving, not text that could end a list.
190
+ scanner.partial(open.endsWith('\r') ? open.slice(0, -1) : open);
191
+ },
192
+ async end() {
193
+ if (!ended) {
194
+ ended = true;
195
+ for (const line of open.replace(/\r\n?/g, '\n').split('\n'))
196
+ scanner.line(line);
197
+ open = '';
198
+ scanner.end();
199
+ }
200
+ while (running)
201
+ await running;
202
+ if (failure)
203
+ throw failure.error;
204
+ },
205
+ };
206
+ }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * What the model reads: the buy tool's answer with every link taken out. A
3
+ * tool runner puts a tool's result straight into the model's context, and a
4
+ * model that holds a link writes it into the chat. So a link never enters
5
+ * this view: `sendLink` delivers it, and the model reads `link_sent` for the
6
+ * Vault and approval links, and `link_sent` on each product and
7
+ * `product_links_sent` for the products' store pages.
8
+ */
9
+ import type { BuyNextAction } from './client.js';
1
10
  import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
2
11
  export interface BuyToolModelCart {
3
12
  store: string;
@@ -19,6 +28,8 @@ export interface BuyToolModelProduct {
19
28
  /** The buy tool's answer as the model reads it. */
20
29
  export interface BuyToolModelView {
21
30
  status: BuyToolStatus;
31
+ /** The one thing to ask the shopper for in this message, or null when nothing waits on them. Never ask for a later step. */
32
+ next_action: BuyNextAction | null;
22
33
  /** Agentcard's answer for the shopper, with every link taken out. */
23
34
  reply: string;
24
35
  /** The newest cart on the table. */
@@ -1,5 +1,5 @@
1
1
  const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
2
- /** The model sees the first twenty products the search found, the cap the backend's catalog keeps for a turn. */
2
+ /** At most twenty products reach the model. Agentcard's /buy answer lists three, so this cap holds only for an older answer. */
3
3
  const MAX_PRODUCTS = 20;
4
4
  const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
5
5
  const SCHEME_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`)\]]*/gi;
@@ -88,6 +88,7 @@ export function forModel(result) {
88
88
  const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
89
89
  return {
90
90
  status,
91
+ next_action: result.next_action ?? null,
91
92
  reply: stripLinks(result.reply),
92
93
  cart: result.cart ? cartView(result.cart) : null,
93
94
  carts: (result.carts ?? []).map(cartView),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and hear what happened to each purchase from a signed webhook.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",