@agent-cards/commerce-sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,7 +74,7 @@ const answer = await new Anthropic().beta.messages.toolRunner({
74
74
 
75
75
  Build the client once and keep it. Every shopper's tool shares the client's access token, and the client renews the token a minute before it expires. Keep each shopper's tool as well. Agentcard keeps each purchase between calls, so a tool built for every message buys the same way, but it sends the same product links again each time Agentcard shows the same products.
76
76
 
77
- The model sends `ask`, `confirm`, `new_order` and `product_id`, and nothing else. Pass `deliveryAddress` when you already hold the shopper's address: the order goes there, and Agentcard never asks the shopper for it. The model can change neither the address nor the shopper.
77
+ The model sends `ask`, `confirm`, `new_order`, `product_id` and `store_id`, and nothing else. Pass `deliveryAddress` when you already hold the shopper's address: the order goes there, and Agentcard never asks the shopper for it. The model can change neither the address nor the shopper.
78
78
 
79
79
  ## Use the tool in any framework
80
80
 
@@ -146,6 +146,16 @@ Each product in the model's view carries a `product_id`, Agentcard's id for it w
146
146
 
147
147
  `result.pick` says what happened: the product's id, and `in_cart`, whether the cart now holds it. The model reads the same `pick` in its view, by the `product_id` it sent. A pick the cart does not hold names no next step, so the model tells the shopper what Agentcard said rather than asking for a card or a yes for an older cart. A product Agentcard no longer holds, because the products are more than 15 minutes old or a newer search replaced them, adds nothing, and the model reads that it should search again with the shopper's words.
148
148
 
149
+ ## Show the stores the shopper picks from
150
+
151
+ A delivery order often starts with a choice of store: the shopper asks for a taco, and Agentcard answers with the taquerias near them that deliver. Each store the reply names reaches `sendLink` as a `store` link, up to three per answer, in the order the reply names them. The link carries the store's page at the merchant in `url`, its name in `title`, Agentcard's id for it in `storeId`, and the delivery estimate in minutes in `etaMinutes`, or null when the store showed none. Show it as a card with an "Order here" button. `result.stores` carries the same stores for your code, each with its `url` and `linkSent`.
152
+
153
+ The model reads each store in `stores` by its `store_id`, name and `eta_minutes`, never its page, and `store_links_sent` says how many store pages the shopper holds. When the shopper picks a store, by its name, its place in the list or a tap on its card, the model passes that `store_id`, and Agentcard selects exactly that store before its agent answers, so the products it finds next are from that store. A card's button can call `buy.answer` with the link's `storeId` as `store_id`. A shopper who answers only with a place in the list, such as `the second one` or `2`, picks the store at that place in the last answer's list, whether or not the model passed the id.
154
+
155
+ `result.store_pick` says what happened: the store's id and name, and `selected`, whether the merchant took the selection; when it did not, Agentcard's agent selects the store itself. The model reads `store_pick` by the `store_id` it sent. A store Agentcard no longer holds, because the stores are more than 15 minutes old or a newer search replaced them, selects nothing, and the model reads that it should search again with the shopper's words.
156
+
157
+ The tool sends each store page once for the calls the model makes in one turn. A later turn whose reply presents the stores again gets their pages again, so every store the reply names arrives with its card. A store link your `sendLink` throws on counts as not sent. Agentcard lists stores only for an answer whose reply presents them as a choice: a turn that searched stores on its way to a menu, or one that shows a cart, lists none.
158
+
149
159
  ## Tell the shopper you are on it
150
160
 
151
161
  A first search can take Agentcard most of a minute. Pass `onWorking`, and the shopper reads an instant reply while Agentcard looks, such as `on it, looking for usb-c cables under $20` or `give me a sec, checking for a table for 2 in north beach`:
@@ -172,9 +182,9 @@ const ack = buy.instantAck(message);
172
182
  if (ack) await textShopper(shopper.id, ack);
173
183
  ```
174
184
 
175
- `instantAck` returns null for a message that starts no search: a confirmation or a short answer such as `yes`, `1` or `ok`, a code, small talk such as `hi` or `how are you`, a message that carries the shopper's own details, such as a name with a phone number, an email address, a street address or a zip code, and any message that answers the last answer's request for shipping or guest details or a code. The reply never reads those details back. Once you call it, the tool sends no reply of its own through `onWorking` for that turn, and the model reads `shopper_already_told_looking: true` when your reply went out, so the shopper hears one acknowledgement.
185
+ `instantAck` returns null for a message that starts no search: a confirmation or a short answer such as `yes`, `1` or `ok`, a pick of a product the shopper already saw, such as `the first one`, `#2`, `I want the blue one` or a card's title with its price, a code, small talk such as `hi` or `how are you`, a message that carries the shopper's own details, such as a name with a phone number, an email address, a street address, a bare house number and street such as `1801 wedemeyer`, or a zip code, and any message that answers the last answer's request for an address, shipping or guest details or a code. The reply never reads those details back, never repeats the shopper's own first-person words, and names the request only when it reads as a short, whole phrase; otherwise it says `on it, give me a moment` or another reply that names nothing. The tool also reads a message that quotes the name of a product its last answer showed as a pick of it. When it returns a reply, the tool sends none of its own through `onWorking` for that turn, and the model reads `shopper_already_told_looking: true`, so the shopper hears one acknowledgement. When it returns null for a message that picks nothing, such as `yes`, a call that runs past `ackDelayMs` still sends `onWorking` a reply that names nothing, such as `give me a sec, checking`.
176
186
 
177
- Without a buy tool, `instantAck(message, { previous, nextAction })` from the package root decides the same way: `previous` is the reply you sent last, so the next one comes from another template, and `nextAction` is the last answer's `next_action`. Pass `ack: false` to the tool when you send it yourself. `ackFor` is the earlier name of the same function.
187
+ Without a buy tool, `instantAck(message, { previous, nextAction, asked })` from the package root decides the same way: `previous` is the reply you sent last, so the next one comes from another template, `nextAction` is the last answer's `next_action`, and `asked` is the last message the shopper read, so an answer to its question for an address, a name or a code gets none. Pass `ack: false` to the tool when you send it yourself. `ackFor` is the earlier name of the same function.
178
188
 
179
189
  ## Show what Agentcard is doing
180
190
 
@@ -251,7 +261,11 @@ The model reads one object per call:
251
261
  "shopper_need_not_reply": false,
252
262
  "shopper_already_told_looking": false,
253
263
  "link_sent": false,
254
- "product_links_sent": 0
264
+ "product_links_sent": 0,
265
+ "pick": null,
266
+ "stores": [],
267
+ "store_links_sent": 0,
268
+ "store_pick": null
255
269
  }
256
270
  ```
257
271
 
package/dist/ack.d.ts CHANGED
@@ -7,9 +7,14 @@
7
7
  *
8
8
  * The paraphrase never carries a link, an email address, a phone number, a street address or a long
9
9
  * number: the text stops before the first one, and a request that starts with one gets the template
10
- * without a paraphrase. A reply to a question, such as `yes`, `2`, `the first one` or a code, gets no
11
- * instant reply at all, because it starts no search.
10
+ * without a paraphrase. It never reads back the shopper's own first-person words, such as `i'll take` or
11
+ * `my mom`, and it never cuts a phrase short: a subject that runs long or carries brackets gets the
12
+ * template without one. A reply to a question, such as `yes`, `2` or a code, and a pick of a product the
13
+ * shopper already saw, such as `the first one`, `I'll take the KODAK AA ($13.49)` or `#2`, gets no
14
+ * instant reply at all, because it starts no search and Agentcard answers it fast.
12
15
  */
16
+ /** True when the shopper's words pick a product they already saw, such as `the first one`, `2` or a card's title with its price. */
17
+ export declare function isPickText(text: string): boolean;
13
18
  /**
14
19
  * True when the shopper's words start a request, and false for an answer to a question, a pick, a
15
20
  * code, small talk, or a message that carries the shopper's own details, such as a phone number or a
@@ -30,7 +35,14 @@ export interface AckForOptions {
30
35
  * shipping or guest details or a code, the next message answers it, so it gets no instant reply.
31
36
  */
32
37
  nextAction?: string | null;
38
+ /**
39
+ * The last message the shopper read from you or from Agentcard. When it asks for an address, a name,
40
+ * a phone number, an email or a code, the next message answers it, so it gets no instant reply.
41
+ */
42
+ asked?: string | null;
33
43
  }
44
+ /** True when a message asks the shopper for their own details or a code, in a question or a request such as `send me your address`. */
45
+ export declare function asksForDetails(message: string | null | undefined): boolean;
34
46
  /**
35
47
  * The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, to
36
48
  * send the moment the message arrives and before your model runs; or null for a message that starts no
package/dist/ack.js CHANGED
@@ -7,8 +7,11 @@
7
7
  *
8
8
  * The paraphrase never carries a link, an email address, a phone number, a street address or a long
9
9
  * number: the text stops before the first one, and a request that starts with one gets the template
10
- * without a paraphrase. A reply to a question, such as `yes`, `2`, `the first one` or a code, gets no
11
- * instant reply at all, because it starts no search.
10
+ * without a paraphrase. It never reads back the shopper's own first-person words, such as `i'll take` or
11
+ * `my mom`, and it never cuts a phrase short: a subject that runs long or carries brackets gets the
12
+ * template without one. A reply to a question, such as `yes`, `2` or a code, and a pick of a product the
13
+ * shopper already saw, such as `the first one`, `I'll take the KODAK AA ($13.49)` or `#2`, gets no
14
+ * instant reply at all, because it starts no search and Agentcard answers it fast.
12
15
  */
13
16
  /** Each template twice: with the paraphrase in place of `{x}`, and without one. */
14
17
  const TEMPLATES = [
@@ -30,6 +33,24 @@ const ANSWERS = new RegExp('^(?:' +
30
33
  '(?:option|number|no|#) ?\\d{1,2}',
31
34
  ].join('|') +
32
35
  ')$');
36
+ /** A status report after a step the shopper was asked to take, such as `connected`, `logged in` or `ok i did it`. */
37
+ const STATUS = /^(?:(?:ok|okay|yes|yep|yeah|alright|cool|great|so|done|there)[\s,]+)*(?:i(?:'ve| have)? |we(?:'ve| have)? )?(?:just |already |finally )?(?:connected|linked|logged in|signed in|signed up|saved|finished|added|done|did it|did that|completed|verified|approved|paid|submitted|set (?:it )?up|all set|all good|good to go|i'?m in|we'?re in|it'?s (?:done|connected|linked|added|saved|in))(?: (?:it|that|them|this|my (?:account|card|address)|the (?:account|card|address)|now|already|too|just now|on my end))*$/;
38
+ /**
39
+ * A question about the conversation, an order or how things work, such as `how did it know to msg that
40
+ * number?`, `is it booked?` or `where is my order`: the model answers it straight away, with no search.
41
+ */
42
+ const ABOUT = /^(?:(?:ok|okay|so|wait|hmm|um|uh|and|but)[\s,]+)*(?:how (?:did|does|do|come|is|was|were|are)|why (?:did|does|do|is|was|are|were|would|didn'?t|isn'?t|won'?t)|what(?:'s| is| was) (?:my|the status|happening|going on|that|this)|what happened|where(?:'s| is| are) (?:my|the order|it)|(?:is|was|has|did|will) (?:it|my|this|that|the order|the booking|the reservation)|did (?:it|that|you) (?:go|work|book|place|send|get|cancel|charge|order|change|add|remove)|will you (?:let|tell|text|notify|email|update|remind|ping|keep (?:me|us) (?:updated|posted|informed|in the loop))|when (?:will|does|do|is|did) (?:it|my|the order|they)|how long (?:will|does|until|till))\b/;
43
+ /** A command to the cart or the order, such as `lets clear the cart`, `remove the taco` or `change the quantity to 2`. */
44
+ const CART_COMMAND = new RegExp('^(?:(?:ok|okay|so|actually|wait|and|um|uh)[\\s,]+)*(?:let\'?s |lets |can you |could you |pls |i want to |i\'?d like to |id like to |i need to |go ahead and |just )*(?:' +
45
+ [
46
+ // Verbs that never open a search: `remove fries`, `cancel order`.
47
+ 'remove|delete|cancel|undo|start over|take off|get rid of',
48
+ // Verbs that also open product names, such as `clear phone case`, `drop earrings` or `take out pizza`, count only before the cart or a reference.
49
+ '(?:clear|empty|reset) (?:out )?(?:the |my |our )?(?:cart|basket|order|everything|it|all)',
50
+ '(?:drop|swap|take out) (?:the|that|this|those|these|it|them|my|our|item|#|\\d|one|both|all|everything)',
51
+ '(?:change|update|edit|reduce|increase|lower|raise|move) (?:the |my |our )?(?:quantity|qty|amount|number|size|order|cart|address|delivery|pickup|time|date|tip|item|reservation|booking|it|that|this|to|the|my|our)',
52
+ ].join('|') +
53
+ ')\\b');
33
54
  /** Small talk that asks for nothing to be bought, after `please` and `thanks` come off. */
34
55
  const CHITCHAT = /^(?:(?:hi|hey|hello|yo|hiya|howdy|sup)(?: there| again)?|good (?:morning|afternoon|evening|night)|g(?:oo)?d ?night|how are (?:you|u)(?: doing)?(?: today)?|how'?s it going|what'?s up|whats up|wassup|who are (?:you|u)|what (?:can|do) (?:you|u) do|are (?:you|u) (?:a )?(?:bot|real|human|there)|you there|test(?:ing)?|bye|see (?:you|ya)|later|love (?:it|you|u)|awesome|amazing|wow|omg|ha(?:ha)+|lmao|:\)|ok cool|cool thanks|so much|very much|a lot)$/;
35
56
  /** Unit words a number names a product by, such as `10000 mah`, so it is never read as a zip code. */
@@ -47,6 +68,17 @@ const PERSONAL = [
47
68
  /\b(?:my name is|my name'?s|name is|i'?m called|my (?:address|number|phone|email|zip)|address is|phone is|email is|zip is|deliver(?:ed)? (?:it )?to|ship(?:ped)? (?:it )?to|send (?:it )?to|bring (?:it )?to|call me|text me)\b/,
48
69
  new RegExp(`(?<![\\w/.$-])\\d{5}(?:-\\d{4})?(?![\\w.,]\\d)(?!\\w)(?!\\s*${UNIT}\\b)`),
49
70
  ];
71
+ /**
72
+ * A message that is only a place, as a shopper answers `what address?`: a house number and a street
73
+ * with no kind, such as `1801 wedemeyer`, a room number, or a named building, such as `the ferry building`.
74
+ * A number before a unit or a plural, such as `100 envelopes` or `100 w projector`, and a year, such as
75
+ * `2024 macbook air`, name a product.
76
+ */
77
+ const PLACE_ONLY = [
78
+ new RegExp(`^(?!(?:19|20)\\d{2}\\b)\\d{2,6}\\s+(?!${UNIT}\\b)[a-z][a-z'.-]*(?:\\s+[a-z][a-z'.-]*){0,2}(?<!s)$`),
79
+ /^(?:room|rm)\s*#?\s*\d{1,5}[a-z]?$/,
80
+ /^the [a-z']+(?: [a-z']+){0,2} (?:building|tower|plaza|station|terminal|airport|center|centre|hall|campus)$/,
81
+ ];
50
82
  /** True when the text holds a phone number (10 to 15 digits, with or without separators) or a run of 12 digits or more, such as a card number. */
51
83
  function hasPersonalNumber(line) {
52
84
  if (/\d{12,}/.test(line))
@@ -58,10 +90,55 @@ function hasPersonalNumber(line) {
58
90
  }
59
91
  return false;
60
92
  }
93
+ /** Words a reply opens with before it names its pick, such as `yes,` in `yes, the first one`. */
94
+ const OPENER = /^(?:(?:yes|yeah|yep|yup|ya|ok|okay|sure|great|perfect|cool|nice|alright|hmm|um|uh|so|then|actually|and)[\s,]+)+/;
95
+ /** A frame that picks from what the shopper saw: `i'll take`, `go with`, `i want the`, `add the`. */
96
+ const PICK_FRAME = new RegExp([
97
+ "^(?:i'?ll|i will|ill|we'?ll|we will|i'?d|id|let'?s|lets|let me) (?:take|go with|go for|pick|choose)\\b",
98
+ "^(?:go|going) (?:with|for) (?:the|that|this|those|these|it|both|option|number)\\b",
99
+ '^(?:i|we) (?:choose|chose|pick|picked)\\b',
100
+ "^(?:i'?ll|i will|ill|i'?d|we'?ll) (?:get|have|buy|order|do|grab) (?:the|that|this|those|these|it|both|one|option|number)\\b",
101
+ "^(?:i want|i'?d like|id like|i'?d prefer|i prefer|we want|we'?d like|i'?ll have|we'?ll have) (?:the|that|this|those|these|it|both)\\b",
102
+ '^add (?:the|that|this|those|these|it|both|one|option|number)\\b',
103
+ '^add #',
104
+ ].join('|'));
105
+ /** A frame in front of a reference to a product, taken off before the reference is read: `can i get` in `can i get the second one`. */
106
+ const ASK_FRAME = /^(?:can i (?:get|have)|could i (?:get|have)|can you (?:get|add|order|buy)|give me|get me|gimme|i need|let me (?:get|have)|buy|order|get)\s+/;
107
+ const PLACE = '(?:first|second|third|fourth|fifth|sixth|last|other|\\d{1,2}(?:st|nd|rd|th))';
108
+ /** A reference to a product the shopper already saw: `that one`, `the blue one`, `the second`, `2nd option`, `#2`, `option 3`. */
109
+ const PICK_REF = new RegExp(`^(?:(?:the|that|this) (?:[\\w'$.-]+ ){0,3}one\\b|(?:that|this|those|these|both|it)(?: ones?)?$|the ${PLACE}(?: (?:one|option|item|product|choice|result))?$|${PLACE}(?: (?:one|option|item|product|choice|result)\\b|$)|(?:option|number|item|choice|#) ?#?\\d{1,2}\\b|\\d{1,2}$)`);
110
+ /** A budget word in front of a price, so `under $13.49` stays a search. */
111
+ const BUDGET = /(?:under|below|than|max|maximum|around|about|to|budget|within|over|above|for|most)\s*$/;
112
+ /**
113
+ * True for a pick of a product the shopper already saw: a frame such as `i'll take` or `i want the`, a
114
+ * reference such as `that one`, `the second` or `#2`, or a product title with its price, as a product
115
+ * card sends it: `KODAK AA Alkaline Batteries (24 Pack) ($13.49)`. Agentcard answers a pick fast, so it
116
+ * gets no instant reply.
117
+ */
118
+ function isPick(line, words) {
119
+ if (/\(\s*\$\s*\d[\d,]*(?:\.\d{2})?\s*\)/.test(line))
120
+ return true;
121
+ const price = line.match(/\$\s*\d[\d,]*\.\d{2}\s*$/);
122
+ if (price && price.index !== undefined && !BUDGET.test(line.slice(0, price.index)))
123
+ return true;
124
+ const said = words.replace(OPENER, '');
125
+ return PICK_FRAME.test(said) || PICK_REF.test(said.replace(ASK_FRAME, ''));
126
+ }
127
+ /** True when the shopper's words pick a product they already saw, such as `the first one`, `2` or a card's title with its price. */
128
+ export function isPickText(text) {
129
+ const line = plain(String(text ?? ''));
130
+ return isPick(line, line.replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim());
131
+ }
61
132
  /** A code or a number on its own, such as `111111` or `4 2 1 9`. */
62
133
  const CODE = /^[a-z]{0,3}\d[\d ]*$/;
63
134
  /** Words that start a request and say nothing about what the shopper wants, taken off the front. */
64
- const LEAD = /^(?:hey|hi|hello|yo|ok|okay|so|also|and|um|uh|actually|just|please|pls|can you|could you|would you|will you|can i (?:get|have)|could i (?:get|have)|i want to|i want|i need to|i need|i would like|i'?d like|id like|i'?m looking for|im looking for|looking for|get|find|order|buy|book|grab|reserve|search for|look for|look up|show|me|us)\b[\s,]*/;
135
+ const LEAD = /^(?:hey|hi|hello|yo|ok|okay|so|also|and|um|uh|actually|just|please|pls|can you|could you|would you|will you|can i (?:get|have)|could i (?:get|have)|i want to|i want|i need to|i need|we want|we need|i would like|i'?d like|id like|we'?d like|i'?m looking for|im looking for|we'?re looking for|looking for|i'?m after|im after|i'?m trying to find|im trying to find|trying to find|i'?m searching for|searching for|hunting for|help me find|help me get|help me|need to|want to|need|want|get|find|order|buy|book|grab|reserve|search for|look for|look up|show|me|us)\b[\s,]*/;
136
+ /** The shopper's own first-person words, which an instant reply never reads back: `i'll take`, `my mom`. */
137
+ const FIRST_PERSON = /\b(?:i|i'm|i'll|i'd|i've|im|ive|me|my|mine|myself|we|we're|we'll|we'd|our|ours|us)\b/;
138
+ /** Words a subject that names a thing to buy never opens with: a verb of a command, or a pronoun. */
139
+ const NOT_A_THING = /^(?:let|lets|let's|clear|empty|remove|delete|cancel|change|update|edit|undo|reset|start|stop|try|use|make|do|put|take|keep|it|its|it's|this|that|these|those|they|them|you|your|he|she|his|her|to|is|are|be|not|no)\b/;
140
+ /** Characters that make a subject read as cut or as a product title rather than a request: brackets, bars and the like. */
141
+ const MESSY = /[()[\]{}<>|\\*_=~^@#]/;
65
142
  /** A request that reads as a question or a change to the purchase: the template goes out without a paraphrase. */
66
143
  const NO_PARAPHRASE = /^(?:what|where|when|why|how|who|which|is|are|was|were|do|does|did|can|could|will|would|should|has|have|change|make|swap|switch|add|remove|drop|cancel|update|use|try|send|deliver|ship|pay|charge|check|tell|give|put|set|move|keep|skip|replace|return|refund|track|reorder|pick|choose|select|go|let|help|see|compare)\b/;
67
144
  /** Where the text stops: before the first link, contact detail, address, long number or delivery clause. */
@@ -78,6 +155,7 @@ const PRIVATE = [
78
155
  /** Words a cut paraphrase never ends on. */
79
156
  const TAIL = /\s+(?:for|with|and|or|but|the|a|an|to|of|in|at|on|from|under|over|below|above|near|by|who|that|which|my|some|around|about)$/;
80
157
  const MAX_WORDS = 8;
158
+ const MAX_WORD_LENGTH = 24;
81
159
  /** The text as one plain line: lowercase, no quotes, and no dash between words. */
82
160
  function plain(text) {
83
161
  return text.toLowerCase().replace(/[‘’]/g, "'").replace(/["“”`]/g, '').replace(/\s+[-–—]+\s+|[–—]/g, ' ').replace(/\s+/g, ' ').trim();
@@ -92,17 +170,18 @@ export function isNewRequest(text) {
92
170
  const words = line.replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
93
171
  if (!/[\p{L}\p{N}]/u.test(words))
94
172
  return false;
95
- if (hasPersonalNumber(line) || PERSONAL.some((pattern) => pattern.test(line)))
173
+ if (hasPersonalNumber(line) || PERSONAL.some((pattern) => pattern.test(line)) || PLACE_ONLY.some((pattern) => pattern.test(words)))
96
174
  return false;
97
- return !ANSWERS.test(words) && !CODE.test(words) && !CHITCHAT.test(words);
175
+ return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !isPick(line, words);
98
176
  }
99
177
  /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
100
178
  export function paraphrase(text) {
101
179
  let rest = plain(String(text ?? '')).split(/[.!?;\n](?:\s|$)/)[0] ?? '';
102
180
  for (const pattern of PRIVATE) {
103
181
  const at = rest.search(pattern);
182
+ // A cut inside a word takes the rest of that word too, so no word is left half read.
104
183
  if (at >= 0)
105
- rest = rest.slice(0, at);
184
+ rest = /\S/.test(rest[at - 1] ?? '') ? rest.slice(0, at).replace(/\S+$/, '') : rest.slice(0, at);
106
185
  }
107
186
  for (let before = ''; before !== rest;) {
108
187
  before = rest;
@@ -112,10 +191,25 @@ export function paraphrase(text) {
112
191
  if (NO_PARAPHRASE.test(rest))
113
192
  return null;
114
193
  const words = rest.split(' ').filter(Boolean);
115
- let short = words.slice(0, MAX_WORDS).join(' ');
194
+ // A long or messy subject is never cut to fit: the template goes out without one.
195
+ if (words.length > MAX_WORDS || words.some((word) => word.length > MAX_WORD_LENGTH))
196
+ return null;
197
+ let short = words.join(' ');
116
198
  while (TAIL.test(short))
117
199
  short = short.replace(TAIL, '');
118
200
  short = short.replace(/[\s,]+$/, '');
201
+ if (MESSY.test(short) || FIRST_PERSON.test(short))
202
+ return null;
203
+ // A house number and a direction, such as `1515 w pacific`, may be an address: the reply names nothing.
204
+ if (/^\d{3,6} [nsew]\.? /.test(short))
205
+ return null;
206
+ // The subject reads back only as a thing to buy or book: never one that opens with a verb or a
207
+ // pronoun, or that names the cart or the order.
208
+ if (NOT_A_THING.test(short) || /\b(?:cart|basket|order|orders|checkout|quantity|qty|items?)\b/.test(short))
209
+ return null;
210
+ // One word that reads as a step or a state, such as `connected` or `linked`, names nothing to buy.
211
+ if (!short.includes(' ') && /(?:ed|ing)$/.test(short))
212
+ return null;
119
213
  return /[a-z]{2}/.test(short) && isNewRequest(short) ? short : null;
120
214
  }
121
215
  /** The template at `index` for these words, with the paraphrase when one is safe. */
@@ -136,6 +230,25 @@ function templateOf(reply) {
136
230
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
137
231
  return TEMPLATES.findIndex(([withWhat, without]) => reply === without || new RegExp(`^${escape(withWhat).replace('\\{x\\}', '.+')}$`).test(reply));
138
232
  }
233
+ /** What a question asks the shopper for when its answer is their own details or a code. */
234
+ const DETAILS = new RegExp([
235
+ "\\byour (?:full |first |last |delivery |shipping |street |home |work |email |e-mail )?(?:address|name|phone(?: number)?|number|email|e-mail|zip(?: code)?|postal code|apartment|apt|unit|suite)\\b",
236
+ '\\b(?:delivery|shipping|street|home|drop-?off) address\\b',
237
+ "\\b(?:what|which) (?:is the |is your |'s the |'s your )?(?:address|name|zip|email)\\b",
238
+ '\\baddress (?:should|do|to|for)\\b',
239
+ '\\bname (?:for|on|under) the (?:order|table|booking|reservation|room|delivery)\\b',
240
+ '\\b(?:phone|mobile|cell|room|apartment|unit|suite|gate|flight) number\\b',
241
+ '\\b(?:email address|zip code|postal code)\\b',
242
+ '\\bwhere (?:should|do|can|would) (?:i|we)\\b.*\\b(?:send|deliver|ship|bring|drop)\\b',
243
+ '\\b(?:verification|confirmation|security|one-time|6-digit|six-digit) code\\b',
244
+ '\\b(?:enter|type|send me|share) the code\\b',
245
+ '\\bthe code (?:we|i|you|they)\\b',
246
+ ].join('|'));
247
+ /** True when a message asks the shopper for their own details or a code, in a question or a request such as `send me your address`. */
248
+ export function asksForDetails(message) {
249
+ const sentences = plain(String(message ?? '')).split(/(?<=[.!?\n])\s+/);
250
+ return sentences.some((sentence) => DETAILS.test(sentence) && (/\?\s*$/.test(sentence) || /^(?:please |pls )?(?:send|tell|share|give|reply|type|enter|text|drop|let me know|i need|i'll need|what|which|where)\b/.test(sentence)));
251
+ }
139
252
  /** The steps a shopper answers with their own details or a code, never with a new request. */
140
253
  const ANSWERED_STEPS = new Set(['shipping_details', 'guest_details', 'verification_code']);
141
254
  /**
@@ -156,6 +269,8 @@ const ANSWERED_STEPS = new Set(['shipping_details', 'guest_details', 'verificati
156
269
  export function instantAck(shopperText, options = {}) {
157
270
  if (typeof options?.nextAction === 'string' && ANSWERED_STEPS.has(options.nextAction))
158
271
  return null;
272
+ if (asksForDetails(options?.asked))
273
+ return null;
159
274
  if (!isNewRequest(shopperText))
160
275
  return null;
161
276
  return ackAt(nextTemplate(templateOf(options?.previous)), shopperText);
package/dist/adapters.js CHANGED
@@ -17,6 +17,7 @@ export function toOpenRouterTool(buy, toolkit) {
17
17
  confirm: z.boolean().describe(properties.confirm.description),
18
18
  new_order: z.boolean().describe(properties.new_order.description),
19
19
  product_id: z.string().nullable().describe(properties.product_id.description),
20
+ store_id: z.string().nullable().describe(properties.store_id.description),
20
21
  }).strict(),
21
22
  // OpenRouter's SDK encodes what execute returns as JSON, so the view goes back as an object and reaches the model as the same text.
22
23
  execute: async (input) => JSON.parse(await buy.execute(input)),
@@ -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 BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type FetchLike } from './client.js';
25
+ import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type BuyStorePick, type BuyTurn, 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
  /**
@@ -41,9 +41,11 @@ export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'pl
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
43
  * `price`, `productId` and `imageUrl`. The page is `url`; `productId` holds no link.
44
+ * `store`: a store's page at the merchant, for a store the shopper can pick, with
45
+ * its name in `title`, `storeId` and `etaMinutes`.
44
46
  */
45
47
  export interface BuyToolLink {
46
- kind: 'vault' | 'approval' | 'store_login' | 'product';
48
+ kind: 'vault' | 'approval' | 'store_login' | 'product' | 'store';
47
49
  url: string;
48
50
  /** When the link stops working, as an ISO 8601 timestamp, or null when Agentcard did not say. Always null on a product link. */
49
51
  expiresAt: string | null;
@@ -61,6 +63,26 @@ export interface BuyToolLink {
61
63
  productId?: string;
62
64
  /** On a product link: the product's picture, or null when Agentcard sent none. Show it beside the title. */
63
65
  imageUrl?: string | null;
66
+ /**
67
+ * On a store link: Agentcard's id for the store, with no link in it. It is the `store_id` the model
68
+ * reads for the same store, and the tool takes it as `store_id`, so a "Order here" button can hand it
69
+ * back as the shopper's pick.
70
+ */
71
+ storeId?: string;
72
+ /** On a store link: the store's delivery estimate in minutes, or null when the store showed none. */
73
+ etaMinutes?: number | null;
74
+ }
75
+ /** A store the answer's reply presents to choose from. */
76
+ export interface BuyToolStore {
77
+ /** Agentcard's id for the store, with no link in it: the model passes it as `store_id` when the shopper picks this store. */
78
+ id: string;
79
+ name: string;
80
+ /** The store's page at the merchant, or null when Agentcard sent none. */
81
+ url: string | null;
82
+ /** The delivery estimate in minutes, or null when the store showed none. */
83
+ etaMinutes: number | null;
84
+ /** True when the shopper holds this store's page: `sendLink` took it in this call or an earlier call of the same model turn. */
85
+ linkSent: boolean;
64
86
  }
65
87
  /** A product the last search found. */
66
88
  export interface BuyToolProduct {
@@ -121,6 +143,10 @@ export interface BuyToolResult {
121
143
  next_action: BuyNextAction | null;
122
144
  /** The product the model's `product_id` picked, by its catalog id, and whether the cart now holds it; null when the call picked none. */
123
145
  pick: BuyPick | null;
146
+ /** The stores the reply presents to choose from, at most three, in the order the reply names them; empty when it presents none. */
147
+ stores: BuyToolStore[];
148
+ /** The store the model's `store_id` picked, or the shopper's `the second one` did, and whether Agentcard selected it; null when none was picked. */
149
+ store_pick: BuyStorePick | null;
124
150
  /** Every cart hash in this answer. */
125
151
  hashes: string[];
126
152
  /** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
@@ -157,7 +183,7 @@ export interface BuyToolOptions {
157
183
  * it before any link or the model's answer. One that throws costs nothing but the instant reply.
158
184
  */
159
185
  onWorking?: (message: string) => void | Promise<void>;
160
- /** `false` sends no instant reply. A function writes it from the shopper's words instead, and an empty answer sends none. */
186
+ /** `false` sends no instant reply. A function writes it from the shopper's words instead, and an empty answer sends none. A turn whose message `instantAck` sent nothing for gets the built-in reply that names nothing. */
161
187
  ack?: false | ((ask: string) => string | null | undefined);
162
188
  /** How long Agentcard has to answer before the instant reply goes out. Default 1500. */
163
189
  ackDelayMs?: number;
@@ -232,12 +258,16 @@ export interface BuyTool {
232
258
  * The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, or
233
259
  * null for a message that starts no search: `yes`, `1`, `ok`, a code, `hi`, or the shopper's own
234
260
  * details, such as a name, a phone number or an address, and any message that answers a request for
235
- * shipping or guest details or a code. Call it the moment the message arrives, before your model runs,
236
- * and send the text it returns. The tool then sends no reply of its own through `onWorking` for that
237
- * turn, and the model reads `shopper_already_told_looking`, so the shopper never hears it twice.
261
+ * shipping or guest details or a code, and a pick of a product the shopper saw. Call it the moment the
262
+ * message arrives, before your model runs, and send the text it returns. When it returns a reply, the
263
+ * tool sends none of its own through `onWorking` for that turn, and the model reads
264
+ * `shopper_already_told_looking`, so the shopper never hears it twice. When it returns null for a
265
+ * message that picks nothing, such as `yes`, a slow call still sends `onWorking` a reply that names nothing.
238
266
  */
239
267
  instantAck(shopperText: string): string | null;
240
268
  }
269
+ /** Shared with the merchant monitor so link eligibility uses the tool's product mapping. */
270
+ export declare function productsOf(turn: BuyTurn): BuyToolProduct[];
241
271
  /**
242
272
  * A buy tool for one shopper. With a company client, name the shopper with
243
273
  * `externalUserId`; with a shopper's own `accessToken`, the token names them.
package/dist/buy-tool.js CHANGED
@@ -24,9 +24,9 @@
24
24
  */
25
25
  import { AgentcardApiError, AgentcardClient, } from './client.js';
26
26
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
27
- import { forModel, forModelError, money, pickId, shownProducts } from './model-view.js';
27
+ import { forModel, forModelError, money, pickId, shownProducts, shownStores } from './model-view.js';
28
28
  import { toMessages } from './messages.js';
29
- import { ackAt, instantAck as instantAckOf, isNewRequest, nextTemplate } from './ack.js';
29
+ import { ackAt, instantAck as instantAckOf, isNewRequest, isPickText, nextTemplate } from './ack.js';
30
30
  /** The pause before the one read that follows a placement Agentcard is still finishing. */
31
31
  const PLACEMENT_WAIT_MS = 12_000;
32
32
  /** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
@@ -46,6 +46,12 @@ const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 't
46
46
  const ACK_DELAY_MS = 1_500;
47
47
  /** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
48
48
  const SAME_TURN_MS = 15_000;
49
+ /** A product name shorter than this, such as `AA`, is too short to tell a pick from a search. */
50
+ const MIN_NAME_LENGTH = 12;
51
+ /** Text as lowercase words with single spaces, so a product's name is found in a message that quotes it. */
52
+ function productWords(text) {
53
+ return String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
54
+ }
49
55
  /** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
50
56
  const APP_TURN_MS = 120_000;
51
57
  const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
@@ -59,6 +65,7 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
59
65
  const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
60
66
  const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
61
67
  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.';
68
+ const STORE_PICK_GONE = "The store the shopper picked is no longer among the stores Agentcard found, so no store was chosen. Call this tool again with the shopper's words and store_id null, and Agentcard searches again.";
62
69
  const PICK_GONE = "The product the shopper picked is no longer among the products Agentcard found, so nothing was added. Call this tool again with the shopper's words and product_id null, and Agentcard searches again.";
63
70
  /**
64
71
  * The pick as a later read of the conversation shows it: a pick the early answer found missing is in
@@ -74,7 +81,8 @@ function pickAfter(pick, carts) {
74
81
  function hashesOf(cart, carts) {
75
82
  return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
76
83
  }
77
- function productsOf(turn) {
84
+ /** Shared with the merchant monitor so link eligibility uses the tool's product mapping. */
85
+ export function productsOf(turn) {
78
86
  return turn.catalog ? productsOfCatalog(turn.catalog) : [];
79
87
  }
80
88
  /** The products of a search, as the tool reads them. */
@@ -94,6 +102,10 @@ function productsOfCatalog(catalog) {
94
102
  linkSent: false,
95
103
  }));
96
104
  }
105
+ /** The stores an answer presents, as the tool reads them. */
106
+ function storesOf(stores) {
107
+ return (stores?.items ?? []).map((store) => ({ id: store.id, name: store.name, url: store.store_page_url, etaMinutes: store.eta_minutes, linkSent: false }));
108
+ }
97
109
  /** The store login link an answer carries, read from its own fields: a link in `reply` is never pulled out. */
98
110
  function storeLoginOf(turn) {
99
111
  if (!turn.store_login_url)
@@ -119,6 +131,8 @@ function answerOf(turn) {
119
131
  error_code: turn.error_code,
120
132
  next_action: turn.next_action,
121
133
  pick: turn.pick,
134
+ stores: storesOf(turn.stores),
135
+ store_pick: turn.store_pick,
122
136
  hashes: hashesOf(turn.cart, turn.carts),
123
137
  envelope: turn.raw,
124
138
  };
@@ -217,6 +231,8 @@ function resultOfRefusal(error) {
217
231
  error_code: error.code,
218
232
  next_action: null,
219
233
  pick: null,
234
+ stores: [],
235
+ store_pick: null,
220
236
  hashes: hashesOf(null, carts),
221
237
  envelope: error.body,
222
238
  });
@@ -230,6 +246,8 @@ function resultOfRefusal(error) {
230
246
  return answer(STILL_WORKING, []);
231
247
  if (error.code === 'pick_not_found')
232
248
  return answer(PICK_GONE, []);
249
+ if (error.code === 'store_pick_not_found')
250
+ return answer(STORE_PICK_GONE, []);
233
251
  return null;
234
252
  }
235
253
  /** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
@@ -251,6 +269,8 @@ function needsCard(link, body) {
251
269
  error_code: 'user_not_connected',
252
270
  next_action: 'add_card',
253
271
  pick: null,
272
+ stores: [],
273
+ store_pick: null,
254
274
  hashes: [],
255
275
  envelope: body,
256
276
  };
@@ -368,29 +388,42 @@ export function buyTool(options) {
368
388
  */
369
389
  let appAck = null;
370
390
  let lastNextAction = null;
391
+ /** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
392
+ let lastReply = null;
393
+ /** True for a turn whose message `instantAck` sent no reply for and that picks nothing: a slow call still tells the shopper, in words that name nothing. */
394
+ let neutralTurn = false;
395
+ /** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
396
+ let shownNames = [];
397
+ const namesShownProduct = (text) => {
398
+ const said = productWords(text);
399
+ return said !== '' && shownNames.some((name) => said.includes(name));
400
+ };
371
401
  /** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
372
402
  const instantAck = (shopperText) => {
373
403
  const text = String(shopperText ?? '');
374
- const said = instantAckOf(text, { nextAction: lastNextAction }) !== null;
404
+ // A shopper who sends a product's name, as a tap on its card does, picks it: no "looking for" reply.
405
+ const pick = namesShownProduct(text) || isPickText(text);
406
+ const said = !pick && instantAckOf(text, { nextAction: lastNextAction, asked: lastReply }) !== null;
375
407
  // The same rotation onWorking uses, so the next reply comes from another template.
376
408
  if (said)
377
409
  lastTemplate = nextTemplate(lastTemplate);
378
- appAck = { at: Date.now(), said };
410
+ appAck = { at: Date.now(), said, pick };
379
411
  return said ? ackAt(lastTemplate, text) : null;
380
412
  };
381
413
  /**
382
414
  * Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
383
415
  * not gone out, and frees the turn's reply for a later call.
384
416
  */
385
- const holdAck = (ask) => {
386
- if (!onWorking || turnAcked || !isNewRequest(ask))
417
+ const holdAck = (ask, neutral) => {
418
+ if (!onWorking || turnAcked || (!neutral && (!isNewRequest(ask) || namesShownProduct(ask))))
387
419
  return null;
388
420
  turnAcked = true;
389
421
  let held = true;
390
422
  const timer = setTimeout(() => {
391
423
  held = false;
392
424
  let text;
393
- if (writeAck) {
425
+ // A neutral turn's message held no request safe to read back, so no writer sees it.
426
+ if (writeAck && !neutral) {
394
427
  try {
395
428
  text = writeAck(ask);
396
429
  }
@@ -400,7 +433,8 @@ export function buyTool(options) {
400
433
  }
401
434
  else {
402
435
  lastTemplate = nextTemplate(lastTemplate);
403
- text = ackAt(lastTemplate, ask);
436
+ // A neutral reply names nothing: the shopper's message held no request safe to read back.
437
+ text = ackAt(lastTemplate, neutral ? '' : ask);
404
438
  }
405
439
  if (typeof text !== 'string' || text.trim() === '') {
406
440
  turnAcked = false;
@@ -688,10 +722,40 @@ export function buyTool(options) {
688
722
  };
689
723
  /** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
690
724
  const earlyPages = (conversationId, asOf, products) => ({ conversation_id: conversationId, envelope: { catalog: { as_of: asOf } }, products });
725
+ /** The store pages this tool sent last, with their conversation and the model turn that sent them. */
726
+ let lastStorePages = null;
727
+ /**
728
+ * The page of each store the reply presents, sent once per page in a model turn. Agentcard lists
729
+ * stores only on an answer whose reply presents them, so a later model turn that presents them
730
+ * again sends their pages again, and the calls of one model turn send each page once. A store
731
+ * whose id holds a link is left out, as the model view leaves it out. A page whose `sendLink`
732
+ * throws stays unsent, and the call goes on.
733
+ */
734
+ const deliverStorePages = async (result, call) => {
735
+ if (!sendLink || !result.stores?.length)
736
+ return result;
737
+ const kept = lastStorePages?.conversationId === result.conversation_id && lastStorePages.turn === call.turn;
738
+ const held = new Set(kept && lastStorePages ? lastStorePages.urls : []);
739
+ for (const store of shownStores(result.stores)) {
740
+ if (!store.url)
741
+ continue;
742
+ if (!held.has(store.url)) {
743
+ try {
744
+ await sendLink({ kind: 'store', url: store.url, title: store.name, storeId: store.id, etaMinutes: store.etaMinutes, expiresAt: null });
745
+ }
746
+ catch {
747
+ continue;
748
+ }
749
+ }
750
+ held.add(store.url);
751
+ }
752
+ lastStorePages = { conversationId: result.conversation_id, turn: call.turn, urls: held };
753
+ return { ...result, stores: result.stores.map((store) => (store.url && held.has(store.url) ? { ...store, linkSent: true } : store)) };
754
+ };
691
755
  /** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
692
756
  let pagesInTurn = Promise.resolve();
693
757
  const deliverPagesInTurn = (result, call) => {
694
- const batch = pagesInTurn.then(() => deliverPages(result, call));
758
+ const batch = pagesInTurn.then(() => deliverPages(result, call)).then((delivered) => deliverStorePages(delivered, call));
695
759
  pagesInTurn = batch.catch(() => { });
696
760
  return batch;
697
761
  };
@@ -713,8 +777,9 @@ export function buyTool(options) {
713
777
  const ask = input.ask.trim();
714
778
  if (!confirm && ask === '')
715
779
  throw new TypeError("Put the shopper's words in ask.");
716
- // A pick chooses from the products this conversation showed: never on a confirm, and never on a new order, which shows none yet.
717
- const pick = !confirm && !input.new_order && input.product_id ? { product_id: input.product_id } : null;
780
+ // A pick chooses from the products or stores this conversation showed: never on a confirm, and never on a new order, which shows none yet.
781
+ const picks = !confirm && !input.new_order;
782
+ const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
718
783
  const body = {
719
784
  resume: true,
720
785
  ...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
@@ -729,13 +794,17 @@ export function buyTool(options) {
729
794
  const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
730
795
  appAck = null;
731
796
  if (opened) {
732
- turnAcked = true;
797
+ // Only a reply the app sent, or a pick, quiets onWorking: a message such as `yes` that starts a
798
+ // slow search still gets a reply that names nothing.
799
+ turnAcked = opened.said || opened.pick;
800
+ neutralTurn = !turnAcked;
733
801
  ackOut = null;
734
802
  ackDelivered = opened.said;
735
803
  modelTurn += 1;
736
804
  }
737
805
  else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
738
806
  turnAcked = false;
807
+ neutralTurn = false;
739
808
  ackOut = null;
740
809
  ackDelivered = false;
741
810
  modelTurn += 1;
@@ -743,7 +812,7 @@ export function buyTool(options) {
743
812
  const callTurn = modelTurn;
744
813
  running += 1;
745
814
  // A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
746
- const ack = confirm || pick ? null : holdAck(ask);
815
+ const ack = confirm || pick ? null : holdAck(ask, neutralTurn);
747
816
  try {
748
817
  let turn = null;
749
818
  let refusal;
@@ -779,6 +848,11 @@ export function buyTool(options) {
779
848
  const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true });
780
849
  const result = ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
781
850
  lastNextAction = result.next_action;
851
+ lastReply = result.reply;
852
+ // An answer that lists products replaces the names, even when every one is too short to keep.
853
+ const shown = shownProducts(result.products);
854
+ if (shown.length > 0)
855
+ shownNames = shown.map((product) => productWords(product.name)).filter((name) => name.length >= MIN_NAME_LENGTH);
782
856
  if (onResult) {
783
857
  try {
784
858
  await onResult(result);
package/dist/client.d.ts CHANGED
@@ -94,10 +94,14 @@ export interface BuyRequest {
94
94
  new_order?: true;
95
95
  /**
96
96
  * The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
97
- * Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
97
+ * Agentcard adds exactly that item, with no new search. Or the store the shopper picked from the last
98
+ * answer's `stores.items`, by its `id`: Agentcard selects exactly that store before the turn runs.
99
+ * Goes with an `ask`, never with `confirm` or `new_order`.
98
100
  */
99
101
  pick?: {
100
102
  product_id: string;
103
+ } | {
104
+ store_id: string;
101
105
  };
102
106
  payment_source?: 'vault';
103
107
  delivery_address?: BuyDeliveryAddress;
@@ -177,6 +181,30 @@ export interface BuyPick {
177
181
  ref: string | null;
178
182
  in_cart: boolean;
179
183
  }
184
+ /**
185
+ * One store a delivery store search found that the answer's reply names. `id` holds no link and is what
186
+ * `pick.store_id` takes; `store_page_url` is the store's page at the merchant. `eta_minutes` is the
187
+ * merchant's delivery estimate, or null when it showed none.
188
+ */
189
+ export interface BuyStoreChoice {
190
+ id: string;
191
+ name: string;
192
+ store_page_url: string | null;
193
+ eta_minutes: number | null;
194
+ }
195
+ /** The stores an answer's reply presents to choose from, at most three, in the order the reply names them. */
196
+ export interface BuyStores {
197
+ merchant: string;
198
+ merchant_name: string;
199
+ items: BuyStoreChoice[];
200
+ as_of: string | null;
201
+ }
202
+ /** The store a store pick named, and whether Agentcard selected it before the turn ran. */
203
+ export interface BuyStorePick {
204
+ store_id: string;
205
+ name: string | null;
206
+ selected: boolean;
207
+ }
180
208
  export interface BuyPaymentSource {
181
209
  source: string;
182
210
  brand: string | null;
@@ -223,6 +251,10 @@ export interface BuyTurn {
223
251
  store_login_merchant_name: string | null;
224
252
  /** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
225
253
  pick: BuyPick | null;
254
+ /** The stores this answer's reply presents to choose from; null when it presents none, and on an older API. */
255
+ stores: BuyStores | null;
256
+ /** The store this turn's `pick.store_id` named, or an answer such as `the second one` picked; null without one. */
257
+ store_pick: BuyStorePick | null;
226
258
  /** The answer as Agentcard sent it. */
227
259
  raw: Record<string, unknown>;
228
260
  }
package/dist/client.js CHANGED
@@ -132,6 +132,18 @@ function catalogOf(v) {
132
132
  }) : [];
133
133
  return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
134
134
  }
135
+ /** The stores an answer presents: each one with an id and a name, and no block when none has both. */
136
+ function storesOf(v) {
137
+ if (!isRecord(v) || !Array.isArray(v.items))
138
+ return null;
139
+ const items = v.items.filter(isRecord).flatMap((i) => {
140
+ const id = str(i.id);
141
+ const name = str(i.name);
142
+ const eta = num(i.eta_minutes);
143
+ return id && name ? [{ id, name, store_page_url: pageUrl(i.store_page_url), eta_minutes: eta }] : [];
144
+ });
145
+ return items.length ? { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, as_of: str(v.as_of) } : null;
146
+ }
135
147
  function paymentSourceOf(v) {
136
148
  return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
137
149
  }
@@ -165,6 +177,8 @@ export function turnOf(body) {
165
177
  store_login_merchant_name: str(body.store_login_merchant_name),
166
178
  next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
167
179
  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,
180
+ stores: storesOf(body.stores),
181
+ store_pick: isRecord(body.store_pick) && str(body.store_pick.store_id) ? { store_id: body.store_pick.store_id, name: str(body.store_pick.name), selected: body.store_pick.selected === true } : null,
168
182
  raw: body,
169
183
  };
170
184
  }
@@ -1,7 +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 four required fields, `product_id` null unless the shopper
4
- * picks a product they were shown. The delivery address and the
3
+ * JSON Schema with five required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
5
5
  * shopper's id are construction options, never model inputs, so a model can
6
6
  * neither change where an order goes nor buy for someone else.
7
7
  */
@@ -9,8 +9,9 @@ export declare const BUY_TOOL_NAME = "buy";
9
9
  /** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
10
10
  export declare const BUY_TOOL_DESCRIPTION: string;
11
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.
12
+ * What the model sends: `ask`, `confirm` and `new_order` every time, `product_id`, null unless the
13
+ * shopper picks a product from the last result, and `store_id`, null unless they pick a store from it.
14
+ * Your own code may leave `product_id` and `store_id` out.
14
15
  */
15
16
  export interface BuyToolInput {
16
17
  ask: string;
@@ -18,6 +19,8 @@ export interface BuyToolInput {
18
19
  new_order: boolean;
19
20
  /** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
20
21
  product_id?: string | null;
22
+ /** The `store_id` of the store the shopper picked from the last result's `stores`, or null. */
23
+ store_id?: string | null;
21
24
  }
22
25
  /** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
23
26
  export type BuyToolInputSchema = {
@@ -39,6 +42,10 @@ export type BuyToolInputSchema = {
39
42
  type: ['string', 'null'];
40
43
  description: string;
41
44
  };
45
+ store_id: {
46
+ type: ['string', 'null'];
47
+ description: string;
48
+ };
42
49
  };
43
50
  required: Array<keyof BuyToolInput>;
44
51
  additionalProperties: false;
@@ -52,8 +59,9 @@ export interface BuyToolDefinition {
52
59
  export declare function buyToolDefinition(): BuyToolDefinition;
53
60
  /**
54
61
  * The model's input, checked against the schema: an object, or its JSON text,
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
62
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
63
+ * `store_id` each a string, null or left out, at most one of them set, and
64
+ * nothing else. Throws a TypeError whose message
57
65
  * tells the model what to send.
58
66
  */
59
67
  export declare function parseBuyToolInput(value: unknown): BuyToolInput;
@@ -1,7 +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 four required fields, `product_id` null unless the shopper
4
- * picks a product they were shown. The delivery address and the
3
+ * JSON Schema with five required fields, `product_id` null unless the shopper
4
+ * picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
5
5
  * shopper's id are construction options, never model inputs, so a model can
6
6
  * neither change where an order goes nor buy for someone else.
7
7
  */
@@ -25,6 +25,8 @@ export const BUY_TOOL_DESCRIPTION = [
25
25
  "After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
26
26
  'Set `new_order` to true only when the shopper drops the cart.',
27
27
  "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.",
28
+ "When the shopper picks one of the `stores` in your last result, by its name, its place in the list or a tap that names it, set `store_id` to that store's `store_id`, so Agentcard orders from exactly that store; otherwise set it to null. Never set both `product_id` and `store_id`.",
29
+ 'Store links reach the shopper on their own, beside your reply, as product links do: `store_links_sent` says how many.',
28
30
  '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.',
29
31
  ].join(' ');
30
32
  /** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
@@ -45,17 +47,22 @@ export function buyToolDefinition() {
45
47
  type: ['string', 'null'],
46
48
  description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
47
49
  },
50
+ store_id: {
51
+ type: ['string', 'null'],
52
+ description: 'The `store_id` of the store the shopper picked from the `stores` in your last result. Null when they picked none.',
53
+ },
48
54
  },
49
- required: ['ask', 'confirm', 'new_order', 'product_id'],
55
+ required: ['ask', 'confirm', 'new_order', 'product_id', 'store_id'],
50
56
  additionalProperties: false,
51
57
  },
52
58
  };
53
59
  }
54
- const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
60
+ const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id', 'store_id']);
55
61
  /**
56
62
  * The model's input, checked against the schema: an object, or its JSON text,
57
- * with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
58
- * string, null or left out, and nothing else. Throws a TypeError whose message
63
+ * with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
64
+ * `store_id` each a string, null or left out, at most one of them set, and
65
+ * nothing else. Throws a TypeError whose message
59
66
  * tells the model what to send.
60
67
  */
61
68
  export function parseBuyToolInput(value) {
@@ -74,7 +81,7 @@ export function parseBuyToolInput(value) {
74
81
  const fields = input;
75
82
  const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
76
83
  if (extra !== undefined)
77
- throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
84
+ throw new TypeError(`The buy tool takes only ask, confirm, new_order, product_id and store_id, and ${extra} is not one of them.`);
78
85
  if (typeof fields.ask !== 'string')
79
86
  throw new TypeError("Set ask to the shopper's words, as a string.");
80
87
  if (typeof fields.confirm !== 'boolean')
@@ -85,7 +92,15 @@ export function parseBuyToolInput(value) {
85
92
  if (productId !== undefined && productId !== null && typeof productId !== 'string') {
86
93
  throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
87
94
  }
88
- return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
95
+ const storeId = fields.store_id;
96
+ if (storeId !== undefined && storeId !== null && typeof storeId !== 'string') {
97
+ throw new TypeError("Set store_id to the picked store's store_id, as a string, or to null.");
98
+ }
99
+ const product = typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null;
100
+ const store = typeof storeId === 'string' && storeId.trim() !== '' ? storeId.trim() : null;
101
+ if (product && store)
102
+ throw new TypeError('Set product_id or store_id for the one thing the shopper picked, and the other to null.');
103
+ return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: product, store_id: store };
89
104
  }
90
105
  /** 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. */
91
106
  export function openAIBuyTool() {
package/dist/index.d.ts CHANGED
@@ -2,12 +2,12 @@ export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatur
2
2
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
5
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, 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, type BuyPick, } from './client.js';
5
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyStreamHandlers, 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, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, } from './client.js';
6
6
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
7
7
  export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
8
- export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
8
+ export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, type BuyToolStore, } from './buy-tool.js';
9
9
  export { instantAck, ackFor, type AckForOptions } from './ack.js';
10
- export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
10
+ export { forModel, forModelError, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, type BuyToolModelStore, } from './model-view.js';
11
11
  export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
12
12
  export { listenToAgentcard, type AgentcardListener, type ListenOptions, type ListenCursorStore, type StreamFetch, } from './listen.js';
13
13
  export { AGENTCARD_EVENT_TYPES, type AgentcardEventType, } from './events.js';
@@ -4,10 +4,11 @@
4
4
  * model that holds a link writes it into the chat. So a link never enters
5
5
  * this view: `sendLink` delivers it, and the model reads `link_sent` for the
6
6
  * Vault and approval links, and `link_sent` on each product and
7
- * `product_links_sent` for the products' store pages.
7
+ * `product_links_sent` for the products' store pages, and `link_sent` on each
8
+ * store and `store_links_sent` for the pages of the stores to pick from.
8
9
  */
9
10
  import type { BuyNextAction } from './client.js';
10
- import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
11
+ import type { BuyToolProduct, BuyToolResult, BuyToolStatus, BuyToolStore } from './buy-tool.js';
11
12
  export interface BuyToolModelCart {
12
13
  store: string;
13
14
  items: Array<{
@@ -29,6 +30,15 @@ export interface BuyToolModelProduct {
29
30
  /** True when the shopper holds this product's store page, which `sendLink` delivered beside the reply. */
30
31
  link_sent: boolean;
31
32
  }
33
+ export interface BuyToolModelStore {
34
+ /** What the model passes as `store_id` when the shopper picks this store: Agentcard's id, never a link. */
35
+ store_id: string;
36
+ name: string;
37
+ /** The delivery estimate in minutes, or null when the store showed none. */
38
+ eta_minutes: number | null;
39
+ /** True when the shopper holds this store's page, which `sendLink` delivered beside the reply. */
40
+ link_sent: boolean;
41
+ }
32
42
  /** The buy tool's answer as the model reads it. */
33
43
  export interface BuyToolModelView {
34
44
  status: BuyToolStatus;
@@ -54,6 +64,15 @@ export interface BuyToolModelView {
54
64
  product_id: string | null;
55
65
  in_cart: boolean;
56
66
  } | null;
67
+ /** The stores the reply presents to choose from, in the order it names them. */
68
+ stores: BuyToolModelStore[];
69
+ /** How many store pages of the stores above the shopper holds, 0 when none went out. */
70
+ store_links_sent: number;
71
+ /** The store this call's `store_id` (or an answer such as `the second one`) picked, and whether Agentcard selected it; null when none was picked. */
72
+ store_pick: {
73
+ store_id: string;
74
+ selected: boolean;
75
+ } | null;
57
76
  }
58
77
  /**
59
78
  * The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
@@ -73,6 +92,11 @@ export declare function money(minor: number | null | undefined, currency: string
73
92
  * shopper never holds a product link the model cannot name.
74
93
  */
75
94
  export declare function shownProducts(products: readonly BuyToolProduct[] | null | undefined): BuyToolProduct[];
95
+ /**
96
+ * The stores the model reads: each one whose id and name hold no link, so the model never reads a
97
+ * link and never offers a store without its name. The tool sends pages for these same stores.
98
+ */
99
+ export declare function shownStores(stores: readonly BuyToolStore[] | null | undefined): BuyToolStore[];
76
100
  /**
77
101
  * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
78
102
  * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
@@ -93,6 +93,13 @@ export function money(minor, currency) {
93
93
  export function shownProducts(products) {
94
94
  return (products ?? []).filter((product) => stripLinks(product.name) !== '' && stripLinks(product.store ?? '') !== '').slice(0, MAX_PRODUCTS);
95
95
  }
96
+ /**
97
+ * The stores the model reads: each one whose id and name hold no link, so the model never reads a
98
+ * link and never offers a store without its name. The tool sends pages for these same stores.
99
+ */
100
+ export function shownStores(stores) {
101
+ return (stores ?? []).filter((store) => store.id !== '' && stripLinks(store.id) === store.id && stripLinks(store.name) !== '');
102
+ }
96
103
  /**
97
104
  * The id the model passes back for a product: Agentcard's link-free ref, else the product's own id when
98
105
  * it holds no link, else null. A retail product's id is its store page, and a link never reaches the model.
@@ -121,6 +128,8 @@ export function forModel(result) {
121
128
  const status = typeof result.status === 'string' && MODEL_STATUSES.has(result.status) ? result.status : 'needs_input';
122
129
  const products = shownProducts(result.products);
123
130
  const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
131
+ const stores = shownStores(result.stores);
132
+ const heldStore = (store) => store.linkSent === true && typeof store.url === 'string' && store.url !== '';
124
133
  return {
125
134
  status,
126
135
  next_action: result.next_action ?? null,
@@ -136,6 +145,12 @@ export function forModel(result) {
136
145
  product_links_sent: new Set(products.filter(held).map((product) => product.url)).size,
137
146
  // The id the model sent, never the catalog id, which is a store page for a retail product.
138
147
  pick: result.pick ? { product_id: pickId({ id: result.pick.product_id, ref: result.pick.ref }), in_cart: result.pick.in_cart === true } : null,
148
+ stores: stores.map((store) => ({ store_id: store.id, name: stripLinks(store.name), eta_minutes: store.etaMinutes, link_sent: heldStore(store) })),
149
+ store_links_sent: new Set(stores.filter(heldStore).map((store) => store.url)).size,
150
+ // A store id with a link in it is never read back to the model.
151
+ store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
152
+ ? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
153
+ : null,
139
154
  };
140
155
  }
141
156
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",