@agent-cards/commerce-sdk 0.5.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
@@ -1,6 +1,6 @@
1
1
  # @agent-cards/commerce-sdk
2
2
 
3
- Give your agent one tool that buys for your shoppers at real stores, and tell each shopper what happened to their purchase in your agent's voice. The buy tool sends what the shopper says to Agentcard, and Agentcard finds the products, builds the cart and places the order once the shopper approves it. Agentcard then sends your webhook endpoint a signed event at each moment a shopper should hear about, such as a card saved, an order going through or an approval link about to expire. The package checks the signature and hands you a message ready to post into that shopper's chat.
3
+ Give your agent one tool that buys for your shoppers at real stores, and tell each shopper what happened to their purchase in your agent's voice. The buy tool sends what the shopper says to Agentcard, and Agentcard finds the products, builds the cart and places the order once the shopper approves it. Agentcard then tells your server about each moment a shopper should hear about, such as a card saved, an order going through or an approval link about to expire, and the package hands you a message ready to post into that shopper's chat. Your server opens the connection to Agentcard, so you host no webhook endpoint.
4
4
 
5
5
  ## Install
6
6
 
@@ -10,6 +10,39 @@ npm install @agent-cards/commerce-sdk
10
10
 
11
11
  The package runs on Node 18 or later and has no dependencies.
12
12
 
13
+ ## Hear from Agentcard without webhooks
14
+
15
+ Call `listen` once when your server starts. Your server opens one connection to Agentcard with your company credentials, and Agentcard sends every event for your company down it, so you need no public URL, no webhook secret and no signature check:
16
+
17
+ ```ts
18
+ import { AgentcardClient } from '@agent-cards/commerce-sdk';
19
+
20
+ const agentcard = new AgentcardClient({ clientId: process.env.AGENTCARD_CLIENT_ID ?? '', clientSecret: process.env.AGENTCARD_CLIENT_SECRET ?? '' });
21
+ agentcard.listen({
22
+ onMessage: (shopperId, message) => postToChat(shopperId, message.text),
23
+ onEvent: (event) => console.log(event.type, event.data),
24
+ });
25
+ ```
26
+
27
+ `postToChat` stands for your own code. `onMessage` gets each message for one of your shoppers under your own id for them, the `externalUserId` you gave the buy tool, with the same ready-to-send `text` as `agentcardWebhook`: a card saved (`card_added`), an order placed, an approval about to expire, a delivery on its way. `onEvent` gets every other event Agentcard sends your company, such as `vault.card_stored`, `checkout_authorization.approved` or `transfer.completed`, as the same envelope a webhook endpoint would receive, with the same `id`. Without `onMessage`, `onEvent` gets every event. `AGENTCARD_EVENT_TYPES` lists every type, and `events: ['order.*', 'purchase.message']` keeps only the ones you name.
28
+
29
+ The listener keeps itself connected. It reconnects after a dropped connection, waiting longer after each failure up to 30 seconds, and reconnects when Agentcard sends no heartbeat for 45 seconds. Each reconnect resumes after the last event it handled, so nothing that happened while it was away is lost, and it skips an event it already handled. Agentcard keeps each event for the stream for 72 hours. Where the stream does not get through, the listener reads the same events every two seconds instead.
30
+
31
+ Events run one at a time, in the order Agentcard recorded them. The listener waits for your callback before it hands over the next event, and a callback that throws runs again, up to five times, before the listener passes the error to `onError` and moves on.
32
+
33
+ A new process starts from the moment it connects. To resume where the last process stopped, pass a `cursor` store. After a resume, Agentcard hands over the last ten seconds of events again, in case one of them arrived late. The listener saves the cursor with the id of each event it handled in that window, so a new process skips the ones it already handled:
34
+
35
+ ```ts
36
+ agentcard.listen({
37
+ onMessage: (shopperId, message) => postToChat(shopperId, message.text),
38
+ cursor: { load: () => redis.get('agentcard:cursor'), save: (cursor) => redis.set('agentcard:cursor', cursor) },
39
+ });
40
+ ```
41
+
42
+ `listen` returns `{ ready, close }`: `ready` resolves once the listener reaches Agentcard, and `close()` stops it after the callback that is running returns. Run one listener per company and mode. Two listeners each hear every event, so with two, skip a `message.eventId` you already stored. Sandbox credentials hear sandbox events, live credentials live ones, and a shopper message is always live.
43
+
44
+ `listen` needs a process that keeps running, such as a Node server, a worker or a container. On a host that ends each request after a few seconds, such as a serverless function, take the events at a webhook endpoint instead, as "Hear from Agentcard at a webhook endpoint" shows.
45
+
13
46
  ## Give your agent the buy tool
14
47
 
15
48
  Build one client with your company's credentials and one buy tool per shopper, then pass the tool to your agent as it is:
@@ -41,7 +74,7 @@ const answer = await new Anthropic().beta.messages.toolRunner({
41
74
 
42
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.
43
76
 
44
- 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.
45
78
 
46
79
  ## Use the tool in any framework
47
80
 
@@ -101,16 +134,28 @@ console.log(await result.getText());
101
134
 
102
135
  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
136
 
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.
137
+ 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`, Agentcard's id for it 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 a later answer in the conversation only stands beside the same products, as a cart answer does, the tool sends none of their links again. When a later turn's reply presents them again, as the answer to a repeated ask does, the tool sends their links again, so every product the reply names arrives with its link. A product link your `sendLink` throws on counts as not sent, and the call goes on.
138
+
139
+ `productId` holds no link: it is the same `product_id` the model reads for that product, and the page is `url`. An answer that only adds a product the shopper picked lists that product alone, so it sends no card for a product nobody offered on that turn, and the pages the search already sent still count as sent on the answers after it.
105
140
 
106
141
  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
142
 
108
143
  ## Add the exact product the shopper picks
109
144
 
110
- Each product in the model's view carries a `product_id`, Agentcard's id for it with no link in it. When the shopper picks one of those products, by its name, its place in the list or a tap on its card that sends its title, the model passes that `product_id`, and Agentcard adds exactly that product from the products it showed, with no new search. Your app sends no id of its own: a "Buy this" button that sends the shopper's words, such as `I'll take the KODAK AA Alkaline Batteries (24 Pack) ($13.49)`, is enough, because the model reads the `product_id` beside that name in its last result. Keep each tool result in the model's history, as a tool runner does, so the model still holds those ids on the shopper's next message.
145
+ Each product in the model's view carries a `product_id`, Agentcard's id for it with no link in it. When the shopper picks one of those products, by its name, its place in the list or a tap on its card that sends its title, the model passes that `product_id`, and Agentcard adds exactly that product from the products it showed, with no new search. Your app sends no id of its own: a "Buy this" button that sends the shopper's words, such as `I'll take the KODAK AA Alkaline Batteries (24 Pack) ($13.49)`, is enough, because the model reads the `product_id` beside that name in its last result. Keep each tool result in the model's history, as a tool runner does, so the model still holds those ids on the shopper's next message. Your own code can pick too: a card's button can call `buy.answer` with the link's `productId` as `product_id`, and Agentcard adds that product.
111
146
 
112
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.
113
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
+
114
159
  ## Tell the shopper you are on it
115
160
 
116
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`:
@@ -126,17 +171,36 @@ const buy = buyTool({
126
171
 
127
172
  The tool sends the reply when Agentcard has not answered a new request within 1.5 seconds, so a quick answer arrives alone. It sends one for the calls the model makes in one turn, where a call that starts within 15 seconds of the last one ending counts as the same turn, and none on a confirm, on a pick of a product the shopper saw, or on an answer such as `yes`, `2`, `the first one` or a code. The words rotate through six templates, and one tool never sends the same template twice in a row. A short paraphrase of the request goes in when the tool can read one: it stops before any link, email address, phone number, street address or long number, and a request that starts with one gets a reply that names nothing, such as `looking for that now`. The call waits for `onWorking`, so the shopper reads the reply before any link and before the model answers. An `onWorking` that throws costs only the instant reply.
128
173
 
174
+ When the instant reply went out, the model reads `shopper_already_told_looking: true` in that turn's answers, and the tool's description tells it not to acknowledge the request again, so its own reply opens with the answer or the question rather than a second `on it`. It reads false on a turn that sent none, and when `onWorking` threw.
175
+
129
176
  Pass `ack: false` to send none, `ack: (ask) => text` to write your own from the shopper's words, where an empty answer sends none, and `ackDelayMs` to change the 1.5 seconds.
130
177
 
131
- The model takes about two seconds to call the tool after the shopper's message arrives. To answer sooner, send `ackFor(message)` the moment the message arrives, before your model runs, and pass `ack: false` to the tool so the shopper reads one reply. `ackFor` returns null for a message that starts no search, and takes the reply you sent last as `{ previous }`, so the next one comes from another template:
178
+ The model takes several seconds to call the tool after the shopper's message arrives. To answer sooner, call `buy.instantAck(message)` the moment the message arrives, before your model runs, and send the text it returns:
132
179
 
133
180
  ```ts
134
- import { ackFor } from '@agent-cards/commerce-sdk';
135
-
136
- const ack = ackFor(message, { previous: lastAck });
181
+ const ack = buy.instantAck(message);
137
182
  if (ack) await textShopper(shopper.id, ack);
138
183
  ```
139
184
 
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`.
186
+
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.
188
+
189
+ ## Show what Agentcard is doing
190
+
191
+ Pass `onProgress`, and the tool asks Agentcard to stream each call. Each stage the call reaches goes to `onProgress` as it happens, `searching`, `building_cart`, `checking_out` or `working`, for a status line such as "searching amazon":
192
+
193
+ ```ts
194
+ const buy = buyTool({
195
+ agentcard,
196
+ externalUserId: shopper.id,
197
+ sendLink: (link) => textShopper(shopper.id, link.url),
198
+ onProgress: ({ stage }) => showStatus(shopper.id, stage),
199
+ });
200
+ ```
201
+
202
+ The product cards still go out with the answer, so each card is a product the reply names. `earlyCards: true` sends the search's first three cards the moment the search answers instead, a few seconds sooner, and the answer then sends only the cards it adds. The reply often recommends other products than the search's first three, so with `earlyCards` a shopper can hold cards the reply never names. A stream that breaks after the call started reads the conversation for what the turn left, as a turn past Agentcard's budget does. Pass `stream: false` to wait for each whole answer.
203
+
140
204
  ## Send each paragraph as its own message
141
205
 
142
206
  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:
@@ -195,13 +259,20 @@ The model reads one object per call:
195
259
  "products": [],
196
260
  "approval_expires_at": null,
197
261
  "shopper_need_not_reply": false,
262
+ "shopper_already_told_looking": false,
198
263
  "link_sent": false,
199
- "product_links_sent": 0
264
+ "product_links_sent": 0,
265
+ "pick": null,
266
+ "stores": [],
267
+ "store_links_sent": 0,
268
+ "store_pick": null
200
269
  }
201
270
  ```
202
271
 
203
272
  `cart` is the newest cart, and `carts` lists every cart on the table, the newest last, so a shopper buying from more than one store hears each one.
204
273
 
274
+ Each product in `products` names its `store`, and a product Agentcard sends without a store is left out, so the model never offers a product without saying where it is sold. No link reaches the model: an address with a path or a scheme is taken out, and a bare one, such as `walmart.com` in `reply`, reads as the site's name, `Walmart`.
275
+
205
276
  `status` says where the purchase stands:
206
277
 
207
278
  | `status` | What happened |
@@ -226,6 +297,20 @@ const view = buy.forModel(result); // the tool result your model reads
226
297
 
227
298
  `buy.run`, `buy.execute` and `buy.handle` each return the same text, `JSON.stringify(buy.forModel(result))`: Anthropic's tool runner calls `buy.run`, the Vercel AI SDK calls `buy.execute`, and your own loop calls `buy.handle`.
228
299
 
300
+ ## Show the cart in your app
301
+
302
+ Pass `onResult` to read every answer while your framework keeps calling `buy.run`, so you never wrap the tool or parse the model's JSON. It gets the whole answer once per call, after its links went out and before the model reads it: the carts, `status`, `next_action` and `order_id`. This one keeps the cart above the chat input:
303
+
304
+ ```ts
305
+ const buy = buyTool({
306
+ agentcard,
307
+ externalUserId: shopper.id,
308
+ onResult: (result) => showCartAboveInput(shopper.id, result.carts, result.next_action),
309
+ });
310
+ ```
311
+
312
+ A refusal the tool turns into an answer, such as the Vault link for a shopper with no card, reaches `onResult` too. A call that throws calls nothing. An `onResult` that throws costs only that call to it, and the model still reads the answer. If you wrap `buy.answer` yourself, hand its errors to `forModelError` before a model reads them, and every link is taken out.
313
+
229
314
  ## Buy with a shopper's own token
230
315
 
231
316
  A tool that buys for one signed-in shopper takes that shopper's own Agentcard access token instead of a client, and names nobody:
@@ -263,9 +348,9 @@ The tool sends each purchase request once, because a purchase turn runs against
263
348
 
264
349
  `buy.answer` throws the `AgentcardApiError` itself. `buy.run`, `buy.execute` and `buy.handle` throw the same message with every link taken out, and keep the original error on `error.cause` for your logs.
265
350
 
266
- ## Hear from Agentcard
351
+ ## Hear from Agentcard at a webhook endpoint
267
352
 
268
- Agentcard tells your webhook endpoint about each moment a shopper should hear about, even when the shopper is not chatting: a card saved, an approval about to expire, an order placed, a delivery on its way. `agentcardWebhook` checks each delivery's signature, skips an event it already handled, and calls `onMessage` with your own id for the shopper and a message ready to post into that shopper's chat:
353
+ A company that prefers webhooks, or runs on a host where no process keeps running, takes the same events at an endpoint of its own. Agentcard tells your webhook endpoint about each moment a shopper should hear about, even when the shopper is not chatting: a card saved, an approval about to expire, an order placed, a delivery on its way. `agentcardWebhook` checks each delivery's signature, skips an event it already handled, and calls `onMessage` with your own id for the shopper and a message ready to post into that shopper's chat:
269
354
 
270
355
  ```ts
271
356
  import express from 'express';
package/dist/ack.d.ts CHANGED
@@ -7,10 +7,19 @@
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.
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;
18
+ /**
19
+ * True when the shopper's words start a request, and false for an answer to a question, a pick, a
20
+ * code, small talk, or a message that carries the shopper's own details, such as a phone number or a
21
+ * street address.
12
22
  */
13
- /** True when the shopper's words start a request, and false for an answer to a question, a pick or a code. */
14
23
  export declare function isNewRequest(text: string): boolean;
15
24
  /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
16
25
  export declare function paraphrase(text: string): string | null;
@@ -21,15 +30,34 @@ export declare function nextTemplate(avoid: number, random?: () => number): numb
21
30
  export interface AckForOptions {
22
31
  /** The instant reply this shopper read last, so the next one comes from another template. */
23
32
  previous?: string;
33
+ /**
34
+ * The step the last purchase answer asked the shopper for, its `next_action`. After a question for
35
+ * shipping or guest details or a code, the next message answers it, so it gets no instant reply.
36
+ */
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;
24
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;
25
46
  /**
26
- * The instant reply for a shopper's message, to send the moment it arrives and before your model
27
- * runs, or null for a message that starts no search, such as `yes`, `2` or a code. The buy tool
28
- * sends one on its own through `onWorking`, so pass `ack: false` to the tool when you send this one.
47
+ * The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, to
48
+ * send the moment the message arrives and before your model runs; or null for a message that starts no
49
+ * search: a confirmation or a short answer such as `yes`, `1` or `ok`, a code, small talk such as `hi`,
50
+ * and the shopper's own details, such as a name, a phone number or an address. The words come from
51
+ * fixed lowercase templates, rotated, and never repeat the shopper's personal details.
52
+ *
53
+ * With the buy tool, call `buy.instantAck(text)` instead: the tool then sends no reply of its own
54
+ * through `onWorking` for that turn, and tells the model the shopper already heard one.
29
55
  *
30
56
  * ```ts
31
- * ackFor('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
32
- * ackFor('yes'); // null
57
+ * instantAck('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
58
+ * instantAck('karen, 628 208 4916, 1801 wedemeyer st'); // null
33
59
  * ```
34
60
  */
61
+ export declare function instantAck(shopperText: string, options?: AckForOptions): string | null;
62
+ /** The earlier name of `instantAck`, kept for apps that call it. */
35
63
  export declare function ackFor(text: string, options?: AckForOptions): string | null;
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,10 +33,112 @@ 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');
54
+ /** Small talk that asks for nothing to be bought, after `please` and `thanks` come off. */
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)$/;
56
+ /** Unit words a number names a product by, such as `10000 mah`, so it is never read as a zip code. */
57
+ const UNIT = '(?:packs?|count|ct|pcs?|pieces?|oz|ml|l|g|kg|gb|tb|mb|mah|w|watts?|v|volts?|lumens?|lm|inch(?:es)?|in|ft|feet|mm|cm|lbs?|dollars?|bucks|rpm|dpi|btu|cal|calories|thread)';
58
+ /**
59
+ * Details a shopper sends about themselves rather than a request: an email address, a street address,
60
+ * an apartment number, or words that hand over a name, a number or a place. Phone numbers, zip codes
61
+ * and card numbers are read by `hasPersonalNumber`.
62
+ */
63
+ const PERSONAL = [
64
+ /\S+@\S+\.\S+/,
65
+ // A house number, a direction, then a street name that is no unit (`16 gb flash drive` is a product), then the street's kind.
66
+ new RegExp(`(?:^|[\\s,])\\d{2,6}\\s+(?:[nsew]\\.?\\s+)?(?!${UNIT}\\b)[a-z][a-z'.-]+(?:\\s+[a-z][a-z'.-]+){0,2}\\s+(?:st|street|ave|avenue|rd|road|blvd|boulevard|dr|drive|ln|lane|way|ct|court|pl|place|ter|terrace|hwy|highway|pkwy|parkway)\\b`),
67
+ /\b(?:apt|apartment|unit|suite)\s*#?\s*\d/,
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/,
69
+ new RegExp(`(?<![\\w/.$-])\\d{5}(?:-\\d{4})?(?![\\w.,]\\d)(?!\\w)(?!\\s*${UNIT}\\b)`),
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
+ ];
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. */
83
+ function hasPersonalNumber(line) {
84
+ if (/\d{12,}/.test(line))
85
+ return true;
86
+ for (const run of line.match(/\+?\d[\d\s().-]{6,}\d/g) ?? []) {
87
+ const digits = run.replace(/\D/g, '').length;
88
+ if (digits >= 10 && digits <= 15)
89
+ return true;
90
+ }
91
+ return false;
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
+ }
33
132
  /** A code or a number on its own, such as `111111` or `4 2 1 9`. */
34
133
  const CODE = /^[a-z]{0,3}\d[\d ]*$/;
35
134
  /** Words that start a request and say nothing about what the shopper wants, taken off the front. */
36
- 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 = /[()[\]{}<>|\\*_=~^@#]/;
37
142
  /** A request that reads as a question or a change to the purchase: the template goes out without a paraphrase. */
38
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/;
39
144
  /** Where the text stops: before the first link, contact detail, address, long number or delivery clause. */
@@ -50,24 +155,33 @@ const PRIVATE = [
50
155
  /** Words a cut paraphrase never ends on. */
51
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)$/;
52
157
  const MAX_WORDS = 8;
158
+ const MAX_WORD_LENGTH = 24;
53
159
  /** The text as one plain line: lowercase, no quotes, and no dash between words. */
54
160
  function plain(text) {
55
161
  return text.toLowerCase().replace(/[‘’]/g, "'").replace(/["“”`]/g, '').replace(/\s+[-–—]+\s+|[–—]/g, ' ').replace(/\s+/g, ' ').trim();
56
162
  }
57
- /** True when the shopper's words start a request, and false for an answer to a question, a pick or a code. */
163
+ /**
164
+ * True when the shopper's words start a request, and false for an answer to a question, a pick, a
165
+ * code, small talk, or a message that carries the shopper's own details, such as a phone number or a
166
+ * street address.
167
+ */
58
168
  export function isNewRequest(text) {
59
- const words = plain(String(text ?? '')).replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
169
+ const line = plain(String(text ?? ''));
170
+ const words = line.replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
60
171
  if (!/[\p{L}\p{N}]/u.test(words))
61
172
  return false;
62
- return !ANSWERS.test(words) && !CODE.test(words);
173
+ if (hasPersonalNumber(line) || PERSONAL.some((pattern) => pattern.test(line)) || PLACE_ONLY.some((pattern) => pattern.test(words)))
174
+ return false;
175
+ return !ANSWERS.test(words) && !STATUS.test(words) && !ABOUT.test(words) && !CART_COMMAND.test(words) && !CODE.test(words) && !CHITCHAT.test(words) && !isPick(line, words);
63
176
  }
64
177
  /** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
65
178
  export function paraphrase(text) {
66
179
  let rest = plain(String(text ?? '')).split(/[.!?;\n](?:\s|$)/)[0] ?? '';
67
180
  for (const pattern of PRIVATE) {
68
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.
69
183
  if (at >= 0)
70
- rest = rest.slice(0, at);
184
+ rest = /\S/.test(rest[at - 1] ?? '') ? rest.slice(0, at).replace(/\S+$/, '') : rest.slice(0, at);
71
185
  }
72
186
  for (let before = ''; before !== rest;) {
73
187
  before = rest;
@@ -77,10 +191,25 @@ export function paraphrase(text) {
77
191
  if (NO_PARAPHRASE.test(rest))
78
192
  return null;
79
193
  const words = rest.split(' ').filter(Boolean);
80
- 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(' ');
81
198
  while (TAIL.test(short))
82
199
  short = short.replace(TAIL, '');
83
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;
84
213
  return /[a-z]{2}/.test(short) && isNewRequest(short) ? short : null;
85
214
  }
86
215
  /** The template at `index` for these words, with the paraphrase when one is safe. */
@@ -101,18 +230,52 @@ function templateOf(reply) {
101
230
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
231
  return TEMPLATES.findIndex(([withWhat, without]) => reply === without || new RegExp(`^${escape(withWhat).replace('\\{x\\}', '.+')}$`).test(reply));
103
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
+ }
252
+ /** The steps a shopper answers with their own details or a code, never with a new request. */
253
+ const ANSWERED_STEPS = new Set(['shipping_details', 'guest_details', 'verification_code']);
104
254
  /**
105
- * The instant reply for a shopper's message, to send the moment it arrives and before your model
106
- * runs, or null for a message that starts no search, such as `yes`, `2` or a code. The buy tool
107
- * sends one on its own through `onWorking`, so pass `ack: false` to the tool when you send this one.
255
+ * The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, to
256
+ * send the moment the message arrives and before your model runs; or null for a message that starts no
257
+ * search: a confirmation or a short answer such as `yes`, `1` or `ok`, a code, small talk such as `hi`,
258
+ * and the shopper's own details, such as a name, a phone number or an address. The words come from
259
+ * fixed lowercase templates, rotated, and never repeat the shopper's personal details.
260
+ *
261
+ * With the buy tool, call `buy.instantAck(text)` instead: the tool then sends no reply of its own
262
+ * through `onWorking` for that turn, and tells the model the shopper already heard one.
108
263
  *
109
264
  * ```ts
110
- * ackFor('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
111
- * ackFor('yes'); // null
265
+ * instantAck('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
266
+ * instantAck('karen, 628 208 4916, 1801 wedemeyer st'); // null
112
267
  * ```
113
268
  */
114
- export function ackFor(text, options = {}) {
115
- if (!isNewRequest(text))
269
+ export function instantAck(shopperText, options = {}) {
270
+ if (typeof options?.nextAction === 'string' && ANSWERED_STEPS.has(options.nextAction))
271
+ return null;
272
+ if (asksForDetails(options?.asked))
116
273
  return null;
117
- return ackAt(nextTemplate(templateOf(options?.previous)), text);
274
+ if (!isNewRequest(shopperText))
275
+ return null;
276
+ return ackAt(nextTemplate(templateOf(options?.previous)), shopperText);
277
+ }
278
+ /** The earlier name of `instantAck`, kept for apps that call it. */
279
+ export function ackFor(text, options = {}) {
280
+ return instantAck(text, options);
118
281
  }
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)),