@agent-cards/commerce-sdk 0.5.0 → 0.8.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 +80 -9
- package/dist/ack.d.ts +22 -6
- package/dist/ack.js +59 -11
- package/dist/buy-tool.d.ts +47 -3
- package/dist/buy-tool.js +124 -21
- package/dist/client.d.ts +60 -6
- package/dist/client.js +144 -12
- package/dist/definition.js +1 -0
- package/dist/events.d.ts +12 -0
- package/dist/events.js +79 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/listen.d.ts +93 -0
- package/dist/listen.js +496 -0
- package/dist/model-view.d.ts +12 -4
- package/dist/model-view.js +36 -9
- package/package.json +2 -2
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
|
|
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:
|
|
@@ -101,13 +134,15 @@ 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`,
|
|
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
|
|
|
@@ -126,17 +161,36 @@ const buy = buyTool({
|
|
|
126
161
|
|
|
127
162
|
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
163
|
|
|
164
|
+
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.
|
|
165
|
+
|
|
129
166
|
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
167
|
|
|
131
|
-
The model takes
|
|
168
|
+
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
169
|
|
|
133
170
|
```ts
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const ack = ackFor(message, { previous: lastAck });
|
|
171
|
+
const ack = buy.instantAck(message);
|
|
137
172
|
if (ack) await textShopper(shopper.id, ack);
|
|
138
173
|
```
|
|
139
174
|
|
|
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.
|
|
176
|
+
|
|
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.
|
|
178
|
+
|
|
179
|
+
## Show what Agentcard is doing
|
|
180
|
+
|
|
181
|
+
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":
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const buy = buyTool({
|
|
185
|
+
agentcard,
|
|
186
|
+
externalUserId: shopper.id,
|
|
187
|
+
sendLink: (link) => textShopper(shopper.id, link.url),
|
|
188
|
+
onProgress: ({ stage }) => showStatus(shopper.id, stage),
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
|
|
140
194
|
## Send each paragraph as its own message
|
|
141
195
|
|
|
142
196
|
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,6 +249,7 @@ The model reads one object per call:
|
|
|
195
249
|
"products": [],
|
|
196
250
|
"approval_expires_at": null,
|
|
197
251
|
"shopper_need_not_reply": false,
|
|
252
|
+
"shopper_already_told_looking": false,
|
|
198
253
|
"link_sent": false,
|
|
199
254
|
"product_links_sent": 0
|
|
200
255
|
}
|
|
@@ -202,6 +257,8 @@ The model reads one object per call:
|
|
|
202
257
|
|
|
203
258
|
`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
259
|
|
|
260
|
+
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`.
|
|
261
|
+
|
|
205
262
|
`status` says where the purchase stands:
|
|
206
263
|
|
|
207
264
|
| `status` | What happened |
|
|
@@ -226,6 +283,20 @@ const view = buy.forModel(result); // the tool result your model reads
|
|
|
226
283
|
|
|
227
284
|
`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
285
|
|
|
286
|
+
## Show the cart in your app
|
|
287
|
+
|
|
288
|
+
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:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const buy = buyTool({
|
|
292
|
+
agentcard,
|
|
293
|
+
externalUserId: shopper.id,
|
|
294
|
+
onResult: (result) => showCartAboveInput(shopper.id, result.carts, result.next_action),
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
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.
|
|
299
|
+
|
|
229
300
|
## Buy with a shopper's own token
|
|
230
301
|
|
|
231
302
|
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 +334,9 @@ The tool sends each purchase request once, because a purchase turn runs against
|
|
|
263
334
|
|
|
264
335
|
`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
336
|
|
|
266
|
-
## Hear from Agentcard
|
|
337
|
+
## Hear from Agentcard at a webhook endpoint
|
|
267
338
|
|
|
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:
|
|
339
|
+
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
340
|
|
|
270
341
|
```ts
|
|
271
342
|
import express from 'express';
|
package/dist/ack.d.ts
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
* without a paraphrase. A reply to a question, such as `yes`, `2`, `the first one` or a code, gets no
|
|
11
11
|
* instant reply at all, because it starts no search.
|
|
12
12
|
*/
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* True when the shopper's words start a request, and false for an answer to a question, a pick, a
|
|
15
|
+
* code, small talk, or a message that carries the shopper's own details, such as a phone number or a
|
|
16
|
+
* street address.
|
|
17
|
+
*/
|
|
14
18
|
export declare function isNewRequest(text: string): boolean;
|
|
15
19
|
/** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
|
|
16
20
|
export declare function paraphrase(text: string): string | null;
|
|
@@ -21,15 +25,27 @@ export declare function nextTemplate(avoid: number, random?: () => number): numb
|
|
|
21
25
|
export interface AckForOptions {
|
|
22
26
|
/** The instant reply this shopper read last, so the next one comes from another template. */
|
|
23
27
|
previous?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The step the last purchase answer asked the shopper for, its `next_action`. After a question for
|
|
30
|
+
* shipping or guest details or a code, the next message answers it, so it gets no instant reply.
|
|
31
|
+
*/
|
|
32
|
+
nextAction?: string | null;
|
|
24
33
|
}
|
|
25
34
|
/**
|
|
26
|
-
* The instant reply for a shopper's message,
|
|
27
|
-
*
|
|
28
|
-
*
|
|
35
|
+
* The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, to
|
|
36
|
+
* send the moment the message arrives and before your model runs; or null for a message that starts no
|
|
37
|
+
* search: a confirmation or a short answer such as `yes`, `1` or `ok`, a code, small talk such as `hi`,
|
|
38
|
+
* and the shopper's own details, such as a name, a phone number or an address. The words come from
|
|
39
|
+
* fixed lowercase templates, rotated, and never repeat the shopper's personal details.
|
|
40
|
+
*
|
|
41
|
+
* With the buy tool, call `buy.instantAck(text)` instead: the tool then sends no reply of its own
|
|
42
|
+
* through `onWorking` for that turn, and tells the model the shopper already heard one.
|
|
29
43
|
*
|
|
30
44
|
* ```ts
|
|
31
|
-
*
|
|
32
|
-
*
|
|
45
|
+
* instantAck('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
|
|
46
|
+
* instantAck('karen, 628 208 4916, 1801 wedemeyer st'); // null
|
|
33
47
|
* ```
|
|
34
48
|
*/
|
|
49
|
+
export declare function instantAck(shopperText: string, options?: AckForOptions): string | null;
|
|
50
|
+
/** The earlier name of `instantAck`, kept for apps that call it. */
|
|
35
51
|
export declare function ackFor(text: string, options?: AckForOptions): string | null;
|
package/dist/ack.js
CHANGED
|
@@ -30,6 +30,34 @@ const ANSWERS = new RegExp('^(?:' +
|
|
|
30
30
|
'(?:option|number|no|#) ?\\d{1,2}',
|
|
31
31
|
].join('|') +
|
|
32
32
|
')$');
|
|
33
|
+
/** Small talk that asks for nothing to be bought, after `please` and `thanks` come off. */
|
|
34
|
+
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
|
+
/** Unit words a number names a product by, such as `10000 mah`, so it is never read as a zip code. */
|
|
36
|
+
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)';
|
|
37
|
+
/**
|
|
38
|
+
* Details a shopper sends about themselves rather than a request: an email address, a street address,
|
|
39
|
+
* an apartment number, or words that hand over a name, a number or a place. Phone numbers, zip codes
|
|
40
|
+
* and card numbers are read by `hasPersonalNumber`.
|
|
41
|
+
*/
|
|
42
|
+
const PERSONAL = [
|
|
43
|
+
/\S+@\S+\.\S+/,
|
|
44
|
+
// 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.
|
|
45
|
+
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`),
|
|
46
|
+
/\b(?:apt|apartment|unit|suite)\s*#?\s*\d/,
|
|
47
|
+
/\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
|
+
new RegExp(`(?<![\\w/.$-])\\d{5}(?:-\\d{4})?(?![\\w.,]\\d)(?!\\w)(?!\\s*${UNIT}\\b)`),
|
|
49
|
+
];
|
|
50
|
+
/** 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
|
+
function hasPersonalNumber(line) {
|
|
52
|
+
if (/\d{12,}/.test(line))
|
|
53
|
+
return true;
|
|
54
|
+
for (const run of line.match(/\+?\d[\d\s().-]{6,}\d/g) ?? []) {
|
|
55
|
+
const digits = run.replace(/\D/g, '').length;
|
|
56
|
+
if (digits >= 10 && digits <= 15)
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
33
61
|
/** A code or a number on its own, such as `111111` or `4 2 1 9`. */
|
|
34
62
|
const CODE = /^[a-z]{0,3}\d[\d ]*$/;
|
|
35
63
|
/** Words that start a request and say nothing about what the shopper wants, taken off the front. */
|
|
@@ -54,12 +82,19 @@ const MAX_WORDS = 8;
|
|
|
54
82
|
function plain(text) {
|
|
55
83
|
return text.toLowerCase().replace(/[‘’]/g, "'").replace(/["“”`]/g, '').replace(/\s+[-–—]+\s+|[–—]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
56
84
|
}
|
|
57
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* True when the shopper's words start a request, and false for an answer to a question, a pick, a
|
|
87
|
+
* code, small talk, or a message that carries the shopper's own details, such as a phone number or a
|
|
88
|
+
* street address.
|
|
89
|
+
*/
|
|
58
90
|
export function isNewRequest(text) {
|
|
59
|
-
const
|
|
91
|
+
const line = plain(String(text ?? ''));
|
|
92
|
+
const words = line.replace(/[!?.,;:]+/g, ' ').replace(/\b(?:please|pls|thanks|thank you|thx|ty)\b/g, ' ').replace(/\s+/g, ' ').trim();
|
|
60
93
|
if (!/[\p{L}\p{N}]/u.test(words))
|
|
61
94
|
return false;
|
|
62
|
-
|
|
95
|
+
if (hasPersonalNumber(line) || PERSONAL.some((pattern) => pattern.test(line)))
|
|
96
|
+
return false;
|
|
97
|
+
return !ANSWERS.test(words) && !CODE.test(words) && !CHITCHAT.test(words);
|
|
63
98
|
}
|
|
64
99
|
/** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
|
|
65
100
|
export function paraphrase(text) {
|
|
@@ -101,18 +136,31 @@ function templateOf(reply) {
|
|
|
101
136
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
137
|
return TEMPLATES.findIndex(([withWhat, without]) => reply === without || new RegExp(`^${escape(withWhat).replace('\\{x\\}', '.+')}$`).test(reply));
|
|
103
138
|
}
|
|
139
|
+
/** The steps a shopper answers with their own details or a code, never with a new request. */
|
|
140
|
+
const ANSWERED_STEPS = new Set(['shipping_details', 'guest_details', 'verification_code']);
|
|
104
141
|
/**
|
|
105
|
-
* The instant reply for a shopper's message,
|
|
106
|
-
*
|
|
107
|
-
*
|
|
142
|
+
* The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, to
|
|
143
|
+
* send the moment the message arrives and before your model runs; or null for a message that starts no
|
|
144
|
+
* search: a confirmation or a short answer such as `yes`, `1` or `ok`, a code, small talk such as `hi`,
|
|
145
|
+
* and the shopper's own details, such as a name, a phone number or an address. The words come from
|
|
146
|
+
* fixed lowercase templates, rotated, and never repeat the shopper's personal details.
|
|
147
|
+
*
|
|
148
|
+
* With the buy tool, call `buy.instantAck(text)` instead: the tool then sends no reply of its own
|
|
149
|
+
* through `onWorking` for that turn, and tells the model the shopper already heard one.
|
|
108
150
|
*
|
|
109
151
|
* ```ts
|
|
110
|
-
*
|
|
111
|
-
*
|
|
152
|
+
* instantAck('can you find me usb-c cables under $20'); // 'on it, looking for usb-c cables under $20', or another template
|
|
153
|
+
* instantAck('karen, 628 208 4916, 1801 wedemeyer st'); // null
|
|
112
154
|
* ```
|
|
113
155
|
*/
|
|
114
|
-
export function
|
|
115
|
-
if (
|
|
156
|
+
export function instantAck(shopperText, options = {}) {
|
|
157
|
+
if (typeof options?.nextAction === 'string' && ANSWERED_STEPS.has(options.nextAction))
|
|
116
158
|
return null;
|
|
117
|
-
|
|
159
|
+
if (!isNewRequest(shopperText))
|
|
160
|
+
return null;
|
|
161
|
+
return ackAt(nextTemplate(templateOf(options?.previous)), shopperText);
|
|
162
|
+
}
|
|
163
|
+
/** The earlier name of `instantAck`, kept for apps that call it. */
|
|
164
|
+
export function ackFor(text, options = {}) {
|
|
165
|
+
return instantAck(text, options);
|
|
118
166
|
}
|
package/dist/buy-tool.d.ts
CHANGED
|
@@ -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 FetchLike } from './client.js';
|
|
25
|
+
import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type FetchLike } from './client.js';
|
|
26
26
|
import { type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolStandardSchema, type OpenAIBuyTool } from './definition.js';
|
|
27
27
|
import { type BuyToolModelView } from './model-view.js';
|
|
28
28
|
/**
|
|
@@ -40,7 +40,7 @@ export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'pl
|
|
|
40
40
|
* card. `approval`: the page where they approve this purchase. `store_login`:
|
|
41
41
|
* the page where they sign in to the store named in `store`, before Agentcard
|
|
42
42
|
* can buy there. `product`: a product's page at its store, with its `title`,
|
|
43
|
-
* `price`, `productId` and `imageUrl`.
|
|
43
|
+
* `price`, `productId` and `imageUrl`. The page is `url`; `productId` holds no link.
|
|
44
44
|
*/
|
|
45
45
|
export interface BuyToolLink {
|
|
46
46
|
kind: 'vault' | 'approval' | 'store_login' | 'product';
|
|
@@ -53,7 +53,11 @@ export interface BuyToolLink {
|
|
|
53
53
|
title?: string;
|
|
54
54
|
/** On a product link: the price as the shopper reads it, such as `$12.99`, or null when Agentcard named none. */
|
|
55
55
|
price?: string | null;
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* On a product link: Agentcard's id for the product, with no link in it. It is the `product_id` the
|
|
58
|
+
* model reads for the same product, and the tool takes it as `product_id`, so a "Buy this" button
|
|
59
|
+
* can hand it back as the shopper's pick. Absent when Agentcard sent no such id, as an older API does.
|
|
60
|
+
*/
|
|
57
61
|
productId?: string;
|
|
58
62
|
/** On a product link: the product's picture, or null when Agentcard sent none. Show it beside the title. */
|
|
59
63
|
imageUrl?: string | null;
|
|
@@ -104,6 +108,11 @@ export interface BuyToolResult {
|
|
|
104
108
|
approval_expires_at: string | null;
|
|
105
109
|
/** True when Agentcard tells the shopper how the order went, so the shopper need not reply. */
|
|
106
110
|
shopper_need_not_reply: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* True when `onWorking` delivered this turn's instant reply, such as `got it, finding a taco`, so the
|
|
113
|
+
* shopper already heard that the request is being worked on. Absent or false when none went out.
|
|
114
|
+
*/
|
|
115
|
+
shopper_already_told_looking?: boolean;
|
|
107
116
|
conversation_id: string | null;
|
|
108
117
|
order_id: string | null;
|
|
109
118
|
decline_code: string | null;
|
|
@@ -152,6 +161,32 @@ export interface BuyToolOptions {
|
|
|
152
161
|
ack?: false | ((ask: string) => string | null | undefined);
|
|
153
162
|
/** How long Agentcard has to answer before the instant reply goes out. Default 1500. */
|
|
154
163
|
ackDelayMs?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Called with the whole answer once per call, after its links went out and before the model reads
|
|
166
|
+
* it, so your app can show the carts, the status, `next_action` or the order id without wrapping the
|
|
167
|
+
* tool. A refusal the tool turns into an answer, such as a Vault link for a shopper with no card,
|
|
168
|
+
* counts; a call that throws calls nothing. One that throws costs nothing but the call to it.
|
|
169
|
+
*/
|
|
170
|
+
onResult?: (result: BuyToolResult) => void | Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Called while Agentcard works on a call, with the stage it reached: `searching`, `building_cart`,
|
|
173
|
+
* `checking_out` or `working`. Show it as a status line. One that throws costs nothing but the call to it.
|
|
174
|
+
*/
|
|
175
|
+
onProgress?: (progress: BuyProgress) => void;
|
|
176
|
+
/**
|
|
177
|
+
* `false` waits for each whole answer. With `onProgress`, or with `earlyCards` and `sendLink`, the
|
|
178
|
+
* tool asks Agentcard to stream the call, and each stage goes to `onProgress` as it happens.
|
|
179
|
+
*/
|
|
180
|
+
stream?: false;
|
|
181
|
+
/**
|
|
182
|
+
* `true` sends the store pages of a search's first three products to `sendLink` the moment the search
|
|
183
|
+
* answers, before Agentcard writes its reply, and the answer then sends only the pages it adds. Off by
|
|
184
|
+
* default: the reply often recommends other products than the search's first three, so a shopper
|
|
185
|
+
* would hold cards the reply never names. Measured on five live searches on 2026-10-20, the early
|
|
186
|
+
* pages arrived 2.2 to 3.3 seconds before the answer, and in four of them the reply named a product
|
|
187
|
+
* outside the first three.
|
|
188
|
+
*/
|
|
189
|
+
earlyCards?: boolean;
|
|
155
190
|
}
|
|
156
191
|
/**
|
|
157
192
|
* The buy tool, in the shape every agent framework takes, because their field
|
|
@@ -193,6 +228,15 @@ export interface BuyTool {
|
|
|
193
228
|
answer(input: BuyToolInput): Promise<BuyToolResult>;
|
|
194
229
|
/** The answer as the model may read it, with every link taken out. */
|
|
195
230
|
forModel(result: BuyToolResult): BuyToolModelView;
|
|
231
|
+
/**
|
|
232
|
+
* The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, or
|
|
233
|
+
* null for a message that starts no search: `yes`, `1`, `ok`, a code, `hi`, or the shopper's own
|
|
234
|
+
* 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.
|
|
238
|
+
*/
|
|
239
|
+
instantAck(shopperText: string): string | null;
|
|
196
240
|
}
|
|
197
241
|
/**
|
|
198
242
|
* A buy tool for one shopper. With a company client, name the shopper with
|