@agent-cards/commerce-sdk 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -3
- package/dist/ack.d.ts +35 -0
- package/dist/ack.js +118 -0
- package/dist/adapters.d.ts +4 -1
- package/dist/adapters.js +1 -0
- package/dist/buy-tool.d.ts +17 -1
- package/dist/buy-tool.js +109 -6
- package/dist/client.d.ts +18 -1
- package/dist/client.js +3 -1
- package/dist/definition.d.ts +15 -4
- package/dist/definition.js +19 -7
- package/dist/handler.d.ts +58 -5
- package/dist/handler.js +207 -46
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/message.d.ts +3 -1
- package/dist/model-view.d.ts +13 -1
- package/dist/model-view.js +13 -2
- package/dist/shopper-message.d.ts +86 -0
- package/dist/shopper-message.js +175 -0
- 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 sends your webhook endpoint a signed
|
|
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.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -41,7 +41,7 @@ const answer = await new Anthropic().beta.messages.toolRunner({
|
|
|
41
41
|
|
|
42
42
|
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
43
|
|
|
44
|
-
The model sends `ask`, `confirm` and `
|
|
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.
|
|
45
45
|
|
|
46
46
|
## Use the tool in any framework
|
|
47
47
|
|
|
@@ -105,6 +105,38 @@ The tool also hands `sendLink` a `product` link for each product a search finds,
|
|
|
105
105
|
|
|
106
106
|
Without `sendLink`, `result.links` still carries each Vault, approval and store login link for your code, each product in `result.products` carries its `url`, and the model still never reads one.
|
|
107
107
|
|
|
108
|
+
## Add the exact product the shopper picks
|
|
109
|
+
|
|
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.
|
|
111
|
+
|
|
112
|
+
`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
|
+
|
|
114
|
+
## Tell the shopper you are on it
|
|
115
|
+
|
|
116
|
+
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`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const buy = buyTool({
|
|
120
|
+
agentcard,
|
|
121
|
+
externalUserId: shopper.id,
|
|
122
|
+
sendLink: (link) => textShopper(shopper.id, link.url),
|
|
123
|
+
onWorking: (text) => textShopper(shopper.id, text),
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
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
|
+
|
|
129
|
+
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
|
+
|
|
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:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { ackFor } from '@agent-cards/commerce-sdk';
|
|
135
|
+
|
|
136
|
+
const ack = ackFor(message, { previous: lastAck });
|
|
137
|
+
if (ack) await textShopper(shopper.id, ack);
|
|
138
|
+
```
|
|
139
|
+
|
|
108
140
|
## Send each paragraph as its own message
|
|
109
141
|
|
|
110
142
|
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:
|
|
@@ -231,7 +263,55 @@ The tool sends each purchase request once, because a purchase turn runs against
|
|
|
231
263
|
|
|
232
264
|
`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.
|
|
233
265
|
|
|
234
|
-
##
|
|
266
|
+
## Hear from Agentcard
|
|
267
|
+
|
|
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:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import express from 'express';
|
|
272
|
+
import { agentcardWebhook } from '@agent-cards/commerce-sdk';
|
|
273
|
+
|
|
274
|
+
const app = express();
|
|
275
|
+
app.post('/agentcard/webhooks', express.raw({ type: 'application/json' }), agentcardWebhook({
|
|
276
|
+
secret: process.env.AGENTCARD_WEBHOOK_SECRET ?? '', // returned once, when you created the endpoint
|
|
277
|
+
onMessage: (shopperId, message) => postToChat(shopperId, message.text),
|
|
278
|
+
}));
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`postToChat` stands for your own code. `shopperId` is the `externalUserId` you gave the buy tool, so the message lands in the chat the purchase started in. In a Next.js route handler, export the same handler's `fetch`:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
const webhook = agentcardWebhook({ secret: process.env.AGENTCARD_WEBHOOK_SECRET ?? '', onMessage: (shopperId, message) => postToChat(shopperId, message.text) });
|
|
285
|
+
export const POST = webhook.fetch;
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Subscribe your endpoint to each event below by name. A catch-all `*` alone does not turn `purchase.message` on. `SHOPPER_EVENT_TYPES` holds the three names:
|
|
289
|
+
|
|
290
|
+
| Event | `kind` | When it arrives | Who wrote `text` |
|
|
291
|
+
|---|---|---|---|
|
|
292
|
+
| `purchase.message` | `card_added` | The shopper saved a card from the card link your app sent, or picked one they keep. | Agentcard |
|
|
293
|
+
| `purchase.message` | `order_placed` | The shopper approved, and Agentcard placed the order. | Agentcard |
|
|
294
|
+
| `purchase.message` | `order_failed` | The order failed after the shopper approved. | Agentcard |
|
|
295
|
+
| `purchase.message` | `order_not_placed_yet` | The shopper approved, and the order is not placed yet. | Agentcard |
|
|
296
|
+
| `purchase.message` | `order_unconfirmed` | The store's reply to the order was lost, so the order may exist, and Agentcard is confirming it. | Agentcard |
|
|
297
|
+
| `purchase.message` | `approval_expiring` | The approval link expires soon. `text` carries the link. | Agentcard |
|
|
298
|
+
| `purchase.message` | `approval_expired` | The approval expired before the shopper answered, and no charge went through. | Agentcard |
|
|
299
|
+
| `purchase.message` | `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. | Agentcard |
|
|
300
|
+
| `purchase.message` | `card_link_expired` | The card link expired before the shopper added a card. | Agentcard |
|
|
301
|
+
| `purchase.message` | `cart_idle` | A cart sat, and nobody sent it for approval. | Agentcard |
|
|
302
|
+
| `order.confirmed` | `order_confirmed` | The retailer accepted the order on its side, with its own order number and delivery window. | The package |
|
|
303
|
+
| `order.updated` | `order_updated` | The order reached a new delivery stage, named in `facts.orderStatus`: `confirmed`, `courier_assigned`, `courier_at_store`, `ready_for_pickup`, `picked_up`, `arriving`, `delivered` or `canceled`. | The package |
|
|
304
|
+
|
|
305
|
+
Every message carries `text`, ready to send as is, `note` for your agent, `facts` with the values one by one, `externalUserId`, `userId`, `conversationId` when Agentcard names the buy conversation, and `eventId`. A kind Agentcard adds later still carries `text`.
|
|
306
|
+
|
|
307
|
+
The handler answers when `onMessage` is done: `200` when it returns, `500` when it throws, and Agentcard then delivers the event again, so a failed post is not lost. It remembers the last 10,000 events it finished, so a redelivery of one runs nothing, and a second delivery that lands while the first still runs waits for its answer. That memory belongs to one process: when more than one server takes deliveries, also skip a `message.eventId` you already stored. A message whose link or window closed before it arrived (`isStale`) reaches no callback. A message for a shopper you gave Agentcard no id for, and every other event, such as `vault.card_stored` or `order.placed`, goes to `onEvent`. Agentcard waits 10 seconds for the answer, so keep `onMessage` short: post the text, or queue the agent turn, then return.
|
|
308
|
+
|
|
309
|
+
To read an event without the handler, `readShopperMessage(event)` returns the same message, or null for an event that carries none.
|
|
310
|
+
|
|
311
|
+
## Mount the purchase message handler
|
|
312
|
+
|
|
313
|
+
`purchaseAgentWebhook` is the handler from earlier versions. It hands you `purchase.message` events only, and leaves deduplication to you. New code takes `agentcardWebhook`.
|
|
314
|
+
|
|
235
315
|
|
|
236
316
|
Subscribe your webhook endpoint to `purchase.message`, then mount the handler on its route with `express.raw`:
|
|
237
317
|
|
|
@@ -304,6 +384,8 @@ The facts are `merchantName`, `totalCents`, `currency`, `cardBrand`, `cardLast4`
|
|
|
304
384
|
| `card_link_unfinished` | The shopper has not added a card from their card link. `text` carries the link again. |
|
|
305
385
|
| `card_link_expired` | The card link expired before the shopper added a card. |
|
|
306
386
|
| `cart_idle` | A cart sat, and nobody sent it for approval. |
|
|
387
|
+
| `order_unconfirmed` | The store's reply to the order was lost, and Agentcard is confirming it. |
|
|
388
|
+
| `card_added` | The shopper saved a card from the card link your app sent. |
|
|
307
389
|
|
|
308
390
|
Agentcard can add a kind later. A kind your code does not know still carries `text`, ready to send as is.
|
|
309
391
|
|
package/dist/ack.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instant reply a shopper reads while Agentcard works on their request, such as `on it, looking
|
|
3
|
+
* for usb-c cables under $20`. A search can take Agentcard most of a minute, and a shopper who hears
|
|
4
|
+
* nothing in that time asks again. The words come from fixed templates, rotated so one shopper never
|
|
5
|
+
* reads the same one twice in a row, with a short paraphrase of the request when one can be read
|
|
6
|
+
* safely from it. No model writes them, so they cost nothing and arrive at once.
|
|
7
|
+
*
|
|
8
|
+
* The paraphrase never carries a link, an email address, a phone number, a street address or a long
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
/** True when the shopper's words start a request, and false for an answer to a question, a pick or a code. */
|
|
14
|
+
export declare function isNewRequest(text: string): boolean;
|
|
15
|
+
/** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
|
|
16
|
+
export declare function paraphrase(text: string): string | null;
|
|
17
|
+
/** The template at `index` for these words, with the paraphrase when one is safe. */
|
|
18
|
+
export declare function ackAt(index: number, text: string): string;
|
|
19
|
+
/** A template other than `avoid`, picked at random. */
|
|
20
|
+
export declare function nextTemplate(avoid: number, random?: () => number): number;
|
|
21
|
+
export interface AckForOptions {
|
|
22
|
+
/** The instant reply this shopper read last, so the next one comes from another template. */
|
|
23
|
+
previous?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
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.
|
|
29
|
+
*
|
|
30
|
+
* ```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
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function ackFor(text: string, options?: AckForOptions): string | null;
|
package/dist/ack.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instant reply a shopper reads while Agentcard works on their request, such as `on it, looking
|
|
3
|
+
* for usb-c cables under $20`. A search can take Agentcard most of a minute, and a shopper who hears
|
|
4
|
+
* nothing in that time asks again. The words come from fixed templates, rotated so one shopper never
|
|
5
|
+
* reads the same one twice in a row, with a short paraphrase of the request when one can be read
|
|
6
|
+
* safely from it. No model writes them, so they cost nothing and arrive at once.
|
|
7
|
+
*
|
|
8
|
+
* The paraphrase never carries a link, an email address, a phone number, a street address or a long
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
/** Each template twice: with the paraphrase in place of `{x}`, and without one. */
|
|
14
|
+
const TEMPLATES = [
|
|
15
|
+
['on it, looking for {x}', 'on it, looking for that now'],
|
|
16
|
+
['give me a sec, checking for {x}', 'give me a sec, checking'],
|
|
17
|
+
['looking for {x} now', 'looking for that now'],
|
|
18
|
+
['got it, finding {x}', 'got it, one moment while i look'],
|
|
19
|
+
['searching for {x}, one moment', 'checking on that for you now'],
|
|
20
|
+
['checking what i can find for {x}', 'on it, give me a moment'],
|
|
21
|
+
];
|
|
22
|
+
/** Whole replies that answer a question the shopper was asked, after `please` and `thanks` come off. */
|
|
23
|
+
const ANSWERS = new RegExp('^(?:' +
|
|
24
|
+
[
|
|
25
|
+
'hi', 'hey', 'hello', 'yo', 'sup', 'lol', 'haha', 'hmm', 'y', 'ya', 'yes', 'yeah', 'yea', 'yep', 'yup', 'sure', 'ok', 'okay', 'k', 'kk', 'no', 'nope', 'nah',
|
|
26
|
+
'confirm(?:ed)?', 'approve[ds]?(?: it)?', 'i approved(?: it)?', 'done', 'added(?: it)?', 'i added(?: it| the card| a card)?',
|
|
27
|
+
'go', 'go ahead', 'do it', '(?:buy|order|place|book) it', 'sounds good', 'perfect', 'great', 'cool', 'nice',
|
|
28
|
+
'cancel', 'stop', 'never ?mind', 'nvm', 'wait', 'hold on', 'same', 'either', 'any', 'whatever',
|
|
29
|
+
'(?:the )?(?:first|second|third|fourth|fifth|last|other|\\d{1,2}(?:st|nd|rd|th)?)(?: one)?', '(?:that|this) one',
|
|
30
|
+
'(?:option|number|no|#) ?\\d{1,2}',
|
|
31
|
+
].join('|') +
|
|
32
|
+
')$');
|
|
33
|
+
/** A code or a number on its own, such as `111111` or `4 2 1 9`. */
|
|
34
|
+
const CODE = /^[a-z]{0,3}\d[\d ]*$/;
|
|
35
|
+
/** 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,]*/;
|
|
37
|
+
/** A request that reads as a question or a change to the purchase: the template goes out without a paraphrase. */
|
|
38
|
+
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
|
+
/** Where the text stops: before the first link, contact detail, address, long number or delivery clause. */
|
|
40
|
+
const PRIVATE = [
|
|
41
|
+
/https?:\/\/|www\./,
|
|
42
|
+
/\S+@\S+/,
|
|
43
|
+
/[\w-]+\.[a-z]{2,}\b/,
|
|
44
|
+
/\+?\d[\d\s().-]{6,}\d/,
|
|
45
|
+
/\d{5,}/,
|
|
46
|
+
/(?:^|\s)\d{3,}\s+(?!(?:packs?|count|ct|pcs?|pieces?|oz|ml|l|g|kg|gb|tb|mah|w|watts?|inch(?:es)?|in|ft|feet|mm|cm|lbs?|dollars?|bucks|thread)\b)[a-z]/,
|
|
47
|
+
/\b\d+\s+(?:[a-z]+\s+){1,2}(?:st|street|ave|avenue|rd|road|blvd|boulevard|dr|drive|ln|lane|way|ct|court|pl|place|ter|terrace|hwy|highway)\b/,
|
|
48
|
+
/\b(?:deliver(?:ed)? (?:it )?to|ship(?:ped)? (?:it )?to|send (?:it )?to|bring (?:it )?to|to my (?:place|house|home|apartment|apt|office|work)|my (?:address|number|phone|email|name|card)|address is|call me|text me|named|called|apt|apartment|unit|suite)\b/,
|
|
49
|
+
];
|
|
50
|
+
/** Words a cut paraphrase never ends on. */
|
|
51
|
+
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
|
+
const MAX_WORDS = 8;
|
|
53
|
+
/** The text as one plain line: lowercase, no quotes, and no dash between words. */
|
|
54
|
+
function plain(text) {
|
|
55
|
+
return text.toLowerCase().replace(/[‘’]/g, "'").replace(/["“”`]/g, '').replace(/\s+[-–—]+\s+|[–—]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
56
|
+
}
|
|
57
|
+
/** True when the shopper's words start a request, and false for an answer to a question, a pick or a code. */
|
|
58
|
+
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();
|
|
60
|
+
if (!/[\p{L}\p{N}]/u.test(words))
|
|
61
|
+
return false;
|
|
62
|
+
return !ANSWERS.test(words) && !CODE.test(words);
|
|
63
|
+
}
|
|
64
|
+
/** A short paraphrase of what the shopper asks for, safe to read back to them, or null when none is. */
|
|
65
|
+
export function paraphrase(text) {
|
|
66
|
+
let rest = plain(String(text ?? '')).split(/[.!?;\n](?:\s|$)/)[0] ?? '';
|
|
67
|
+
for (const pattern of PRIVATE) {
|
|
68
|
+
const at = rest.search(pattern);
|
|
69
|
+
if (at >= 0)
|
|
70
|
+
rest = rest.slice(0, at);
|
|
71
|
+
}
|
|
72
|
+
for (let before = ''; before !== rest;) {
|
|
73
|
+
before = rest;
|
|
74
|
+
rest = rest.replace(LEAD, '');
|
|
75
|
+
}
|
|
76
|
+
rest = rest.replace(/[\s,.:!?]+$/, '');
|
|
77
|
+
if (NO_PARAPHRASE.test(rest))
|
|
78
|
+
return null;
|
|
79
|
+
const words = rest.split(' ').filter(Boolean);
|
|
80
|
+
let short = words.slice(0, MAX_WORDS).join(' ');
|
|
81
|
+
while (TAIL.test(short))
|
|
82
|
+
short = short.replace(TAIL, '');
|
|
83
|
+
short = short.replace(/[\s,]+$/, '');
|
|
84
|
+
return /[a-z]{2}/.test(short) && isNewRequest(short) ? short : null;
|
|
85
|
+
}
|
|
86
|
+
/** The template at `index` for these words, with the paraphrase when one is safe. */
|
|
87
|
+
export function ackAt(index, text) {
|
|
88
|
+
const [withWhat, without] = TEMPLATES[index % TEMPLATES.length];
|
|
89
|
+
const what = paraphrase(text);
|
|
90
|
+
return what ? withWhat.replace('{x}', what) : without;
|
|
91
|
+
}
|
|
92
|
+
/** A template other than `avoid`, picked at random. */
|
|
93
|
+
export function nextTemplate(avoid, random = Math.random) {
|
|
94
|
+
const pick = Math.floor(random() * (TEMPLATES.length - 1));
|
|
95
|
+
return avoid < 0 || pick < avoid ? pick : pick + 1;
|
|
96
|
+
}
|
|
97
|
+
/** The template an earlier instant reply came from, or -1. */
|
|
98
|
+
function templateOf(reply) {
|
|
99
|
+
if (!reply)
|
|
100
|
+
return -1;
|
|
101
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
|
+
return TEMPLATES.findIndex(([withWhat, without]) => reply === without || new RegExp(`^${escape(withWhat).replace('\\{x\\}', '.+')}$`).test(reply));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
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.
|
|
108
|
+
*
|
|
109
|
+
* ```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
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export function ackFor(text, options = {}) {
|
|
115
|
+
if (!isNewRequest(text))
|
|
116
|
+
return null;
|
|
117
|
+
return ackAt(nextTemplate(templateOf(options?.previous)), text);
|
|
118
|
+
}
|
package/dist/adapters.d.ts
CHANGED
|
@@ -14,13 +14,16 @@ import type { BuyTool } from './buy-tool.js';
|
|
|
14
14
|
export type AnthropicBuyTool = BuyTool;
|
|
15
15
|
/** @deprecated Pass the buy tool itself: `toolRunner({ tools: [buy] })`. Returns the same object, for one release. */
|
|
16
16
|
export declare function toAnthropicTool(tool: BuyTool): BuyTool;
|
|
17
|
-
/** The
|
|
17
|
+
/** The zod builders the OpenRouter adapter calls; `z` from zod 3 or 4 has them. */
|
|
18
18
|
export interface ZodLike {
|
|
19
19
|
object(shape: Record<string, unknown>): {
|
|
20
20
|
strict(): unknown;
|
|
21
21
|
};
|
|
22
22
|
string(): {
|
|
23
23
|
describe(description: string): unknown;
|
|
24
|
+
nullable(): {
|
|
25
|
+
describe(description: string): unknown;
|
|
26
|
+
};
|
|
24
27
|
};
|
|
25
28
|
boolean(): {
|
|
26
29
|
describe(description: string): unknown;
|
package/dist/adapters.js
CHANGED
|
@@ -16,6 +16,7 @@ export function toOpenRouterTool(buy, toolkit) {
|
|
|
16
16
|
ask: z.string().describe(properties.ask.description),
|
|
17
17
|
confirm: z.boolean().describe(properties.confirm.description),
|
|
18
18
|
new_order: z.boolean().describe(properties.new_order.description),
|
|
19
|
+
product_id: z.string().nullable().describe(properties.product_id.description),
|
|
19
20
|
}).strict(),
|
|
20
21
|
// 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.
|
|
21
22
|
execute: async (input) => JSON.parse(await buy.execute(input)),
|
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 FetchLike } from './client.js';
|
|
25
|
+
import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, 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
|
/**
|
|
@@ -62,6 +62,8 @@ export interface BuyToolLink {
|
|
|
62
62
|
export interface BuyToolProduct {
|
|
63
63
|
flight?: BuyFlightItinerary;
|
|
64
64
|
id: string;
|
|
65
|
+
/** Agentcard's link-free id for the product, which the model passes as `product_id` when the shopper picks it; null from an older API. */
|
|
66
|
+
ref: string | null;
|
|
65
67
|
name: string;
|
|
66
68
|
/** The price in the minor unit of `currency`. */
|
|
67
69
|
priceCents: number | null;
|
|
@@ -108,6 +110,8 @@ export interface BuyToolResult {
|
|
|
108
110
|
error_code: string | null;
|
|
109
111
|
/** The one step the shopper takes next, as Agentcard named it, or null. Only that step's link is in `links`. */
|
|
110
112
|
next_action: BuyNextAction | null;
|
|
113
|
+
/** 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. */
|
|
114
|
+
pick: BuyPick | null;
|
|
111
115
|
/** Every cart hash in this answer. */
|
|
112
116
|
hashes: string[];
|
|
113
117
|
/** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
|
|
@@ -136,6 +140,18 @@ export interface BuyToolOptions {
|
|
|
136
140
|
fetch?: FetchLike;
|
|
137
141
|
/** The pause between two reads of the conversation. Default: a timer. */
|
|
138
142
|
wait?: (ms: number) => Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Sends the shopper an instant reply, such as `on it, looking for usb-c cables under $20`, while
|
|
145
|
+
* Agentcard works on a new request. It goes out when Agentcard has not answered within
|
|
146
|
+
* `ackDelayMs`, once for the calls the model makes in one turn, and never on a confirm, a pick or a
|
|
147
|
+
* reply such as `yes`, `2` or a code. The call waits for it before it returns, so the shopper reads
|
|
148
|
+
* it before any link or the model's answer. One that throws costs nothing but the instant reply.
|
|
149
|
+
*/
|
|
150
|
+
onWorking?: (message: string) => void | Promise<void>;
|
|
151
|
+
/** `false` sends no instant reply. A function writes it from the shopper's words instead, and an empty answer sends none. */
|
|
152
|
+
ack?: false | ((ask: string) => string | null | undefined);
|
|
153
|
+
/** How long Agentcard has to answer before the instant reply goes out. Default 1500. */
|
|
154
|
+
ackDelayMs?: number;
|
|
139
155
|
}
|
|
140
156
|
/**
|
|
141
157
|
* The buy tool, in the shape every agent framework takes, because their field
|
package/dist/buy-tool.js
CHANGED
|
@@ -26,6 +26,7 @@ import { AgentcardApiError, AgentcardClient, } from './client.js';
|
|
|
26
26
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
27
27
|
import { forModel, forModelError, money, shownProducts } from './model-view.js';
|
|
28
28
|
import { toMessages } from './messages.js';
|
|
29
|
+
import { ackAt, isNewRequest, nextTemplate } from './ack.js';
|
|
29
30
|
/** The pause before the one read that follows a placement Agentcard is still finishing. */
|
|
30
31
|
const PLACEMENT_WAIT_MS = 12_000;
|
|
31
32
|
/** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
|
|
@@ -41,6 +42,10 @@ const RESUME_SKEW_MS = 2 * 60_000;
|
|
|
41
42
|
const STOPPED_ORDER = new Set(['failed', 'cancelled']);
|
|
42
43
|
/** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
|
|
43
44
|
const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
|
|
45
|
+
/** How long Agentcard has to answer before the instant reply goes out. */
|
|
46
|
+
const ACK_DELAY_MS = 1_500;
|
|
47
|
+
/** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
|
|
48
|
+
const SAME_TURN_MS = 15_000;
|
|
44
49
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
45
50
|
const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
|
|
46
51
|
const STILL_PLACING = 'The shopper approved, and the order is still going through.';
|
|
@@ -52,6 +57,18 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
|
|
|
52
57
|
const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
|
|
53
58
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
54
59
|
const ADDRESS_CHANGED = 'The delivery address changed since this cart was built. Ask for the cart again, and Agentcard prices it for the new address.';
|
|
60
|
+
const PICK_GONE = "The product the shopper picked is no longer among the products Agentcard found, so nothing was added. Call this tool again with the shopper's words and product_id null, and Agentcard searches again.";
|
|
61
|
+
/**
|
|
62
|
+
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
63
|
+
* the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
|
|
64
|
+
* its store page, as its page. A pick already in the cart, or none, stays as it was.
|
|
65
|
+
*/
|
|
66
|
+
function pickAfter(pick, carts) {
|
|
67
|
+
if (!pick || pick.in_cart)
|
|
68
|
+
return pick;
|
|
69
|
+
const held = carts.some((c) => c.items.some((line) => line.product_id === pick.product_id || line.url === pick.product_id));
|
|
70
|
+
return held ? { ...pick, in_cart: true } : pick;
|
|
71
|
+
}
|
|
55
72
|
function hashesOf(cart, carts) {
|
|
56
73
|
return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
|
|
57
74
|
}
|
|
@@ -60,6 +77,7 @@ function productsOf(turn) {
|
|
|
60
77
|
.filter((item) => item.name)
|
|
61
78
|
.map((item) => ({
|
|
62
79
|
id: item.id,
|
|
80
|
+
ref: item.ref,
|
|
63
81
|
name: item.name,
|
|
64
82
|
priceCents: item.priceCents,
|
|
65
83
|
currency: item.currency,
|
|
@@ -94,6 +112,7 @@ function answerOf(turn) {
|
|
|
94
112
|
decline_code: turn.decline_code,
|
|
95
113
|
error_code: turn.error_code,
|
|
96
114
|
next_action: turn.next_action,
|
|
115
|
+
pick: turn.pick,
|
|
97
116
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
98
117
|
envelope: turn.raw,
|
|
99
118
|
};
|
|
@@ -191,6 +210,7 @@ function resultOfRefusal(error) {
|
|
|
191
210
|
decline_code: null,
|
|
192
211
|
error_code: error.code,
|
|
193
212
|
next_action: null,
|
|
213
|
+
pick: null,
|
|
194
214
|
hashes: hashesOf(null, carts),
|
|
195
215
|
envelope: error.body,
|
|
196
216
|
});
|
|
@@ -202,6 +222,8 @@ function resultOfRefusal(error) {
|
|
|
202
222
|
return answer(NOTHING_TO_CONFIRM, []);
|
|
203
223
|
if (error.code === 'turn_in_progress')
|
|
204
224
|
return answer(STILL_WORKING, []);
|
|
225
|
+
if (error.code === 'pick_not_found')
|
|
226
|
+
return answer(PICK_GONE, []);
|
|
205
227
|
return null;
|
|
206
228
|
}
|
|
207
229
|
/** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
|
|
@@ -222,6 +244,7 @@ function needsCard(link, body) {
|
|
|
222
244
|
decline_code: null,
|
|
223
245
|
error_code: 'user_not_connected',
|
|
224
246
|
next_action: 'add_card',
|
|
247
|
+
pick: null,
|
|
225
248
|
hashes: [],
|
|
226
249
|
envelope: body,
|
|
227
250
|
};
|
|
@@ -299,6 +322,63 @@ export function buyTool(options) {
|
|
|
299
322
|
if (typeof wait !== 'function')
|
|
300
323
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
301
324
|
const who = client.company ? { externalUserId } : {};
|
|
325
|
+
if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
|
|
326
|
+
throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
|
|
327
|
+
if (o.ack !== undefined && o.ack !== false && typeof o.ack !== 'function')
|
|
328
|
+
throw new TypeError('Pass ack as false, or as a function that writes the instant reply from the ask.');
|
|
329
|
+
const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
|
|
330
|
+
if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
|
|
331
|
+
throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
|
|
332
|
+
const onWorking = o.ack === false ? undefined : o.onWorking;
|
|
333
|
+
const writeAck = typeof o.ack === 'function' ? o.ack : null;
|
|
334
|
+
/** Calls running now, when the last one ended, whether this turn's instant reply is taken, and the template it used last. */
|
|
335
|
+
let running = 0;
|
|
336
|
+
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
337
|
+
let turnAcked = false;
|
|
338
|
+
let lastTemplate = -1;
|
|
339
|
+
/** This turn's instant reply while it is being sent: every call of the turn waits for it before it hands over a link. */
|
|
340
|
+
let ackOut = null;
|
|
341
|
+
/**
|
|
342
|
+
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
343
|
+
* not gone out, and frees the turn's reply for a later call.
|
|
344
|
+
*/
|
|
345
|
+
const holdAck = (ask) => {
|
|
346
|
+
if (!onWorking || turnAcked || !isNewRequest(ask))
|
|
347
|
+
return null;
|
|
348
|
+
turnAcked = true;
|
|
349
|
+
let held = true;
|
|
350
|
+
const timer = setTimeout(() => {
|
|
351
|
+
held = false;
|
|
352
|
+
let text;
|
|
353
|
+
if (writeAck) {
|
|
354
|
+
try {
|
|
355
|
+
text = writeAck(ask);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
text = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
lastTemplate = nextTemplate(lastTemplate);
|
|
363
|
+
text = ackAt(lastTemplate, ask);
|
|
364
|
+
}
|
|
365
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
366
|
+
turnAcked = false;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const message = text.trim();
|
|
370
|
+
ackOut = Promise.resolve().then(() => onWorking(message)).catch(() => { });
|
|
371
|
+
}, ackDelayMs);
|
|
372
|
+
return {
|
|
373
|
+
cancel: () => {
|
|
374
|
+
if (!held)
|
|
375
|
+
return;
|
|
376
|
+
held = false;
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
turnAcked = false;
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
};
|
|
302
382
|
/** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
|
|
303
383
|
const read = async (conversationId) => {
|
|
304
384
|
try {
|
|
@@ -456,6 +536,8 @@ export function buyTool(options) {
|
|
|
456
536
|
error_code: newest ? null : base.error_code,
|
|
457
537
|
// A read replaced the answer the step was named for, and names no step of its own: the reply asks.
|
|
458
538
|
next_action: null,
|
|
539
|
+
// The early answer judged the pick before the turn finished adding it.
|
|
540
|
+
pick: pickAfter(base.pick, conversation.carts),
|
|
459
541
|
envelope: conversation.raw,
|
|
460
542
|
};
|
|
461
543
|
if (!conversation.turn_in_progress)
|
|
@@ -572,22 +654,43 @@ export function buyTool(options) {
|
|
|
572
654
|
const ask = input.ask.trim();
|
|
573
655
|
if (!confirm && ask === '')
|
|
574
656
|
throw new TypeError("Put the shopper's words in ask.");
|
|
657
|
+
// A pick chooses from the products this conversation showed: never on a confirm, and never on a new order, which shows none yet.
|
|
658
|
+
const pick = !confirm && !input.new_order && input.product_id ? { product_id: input.product_id } : null;
|
|
575
659
|
const body = {
|
|
576
660
|
resume: true,
|
|
577
661
|
...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
|
|
578
662
|
...(input.new_order ? { new_order: true } : {}),
|
|
663
|
+
...(pick ? { pick } : {}),
|
|
579
664
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
580
665
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
581
666
|
};
|
|
582
|
-
|
|
583
|
-
|
|
667
|
+
// A call that starts while another runs, or right after one ended, is the model working through the same turn.
|
|
668
|
+
if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
|
|
669
|
+
turnAcked = false;
|
|
670
|
+
ackOut = null;
|
|
671
|
+
}
|
|
672
|
+
running += 1;
|
|
673
|
+
// A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
|
|
674
|
+
const ack = confirm || pick ? null : holdAck(ask);
|
|
584
675
|
try {
|
|
585
|
-
turn =
|
|
676
|
+
let turn = null;
|
|
677
|
+
let refusal;
|
|
678
|
+
try {
|
|
679
|
+
turn = await client.buy(body);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
refusal = error;
|
|
683
|
+
}
|
|
684
|
+
ack?.cancel();
|
|
685
|
+
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
|
|
686
|
+
await ackOut;
|
|
687
|
+
return await deliver(withMessages(settled, turn));
|
|
586
688
|
}
|
|
587
|
-
|
|
588
|
-
|
|
689
|
+
finally {
|
|
690
|
+
ack?.cancel();
|
|
691
|
+
running -= 1;
|
|
692
|
+
lastEndedAt = Date.now();
|
|
589
693
|
}
|
|
590
|
-
return deliver(withMessages(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt), turn));
|
|
591
694
|
};
|
|
592
695
|
/** Checks the model's input, with every link taken out of a refusal. */
|
|
593
696
|
const parse = (input) => {
|
package/dist/client.d.ts
CHANGED
|
@@ -51,6 +51,13 @@ export interface BuyRequest {
|
|
|
51
51
|
resume?: true;
|
|
52
52
|
/** Drop the cart on the table and start a new order. */
|
|
53
53
|
new_order?: true;
|
|
54
|
+
/**
|
|
55
|
+
* The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
|
|
56
|
+
* Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
|
|
57
|
+
*/
|
|
58
|
+
pick?: {
|
|
59
|
+
product_id: string;
|
|
60
|
+
};
|
|
54
61
|
payment_source?: 'vault';
|
|
55
62
|
delivery_address?: BuyDeliveryAddress;
|
|
56
63
|
external_user_id?: string;
|
|
@@ -101,6 +108,8 @@ export interface BuyFlightItinerary {
|
|
|
101
108
|
export interface BuyCatalogItem {
|
|
102
109
|
flight?: BuyFlightItinerary;
|
|
103
110
|
id: string;
|
|
111
|
+
/** The item's id with no link in it, which `pick.product_id` takes as it takes `id`; null from an older API. */
|
|
112
|
+
ref: string | null;
|
|
104
113
|
name: string | null;
|
|
105
114
|
priceCents: number | null;
|
|
106
115
|
/** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
|
|
@@ -116,6 +125,12 @@ export interface BuyCatalog {
|
|
|
116
125
|
merchant_name: string;
|
|
117
126
|
items: BuyCatalogItem[];
|
|
118
127
|
}
|
|
128
|
+
/** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
|
|
129
|
+
export interface BuyPick {
|
|
130
|
+
product_id: string;
|
|
131
|
+
ref: string | null;
|
|
132
|
+
in_cart: boolean;
|
|
133
|
+
}
|
|
119
134
|
export interface BuyPaymentSource {
|
|
120
135
|
source: string;
|
|
121
136
|
brand: string | null;
|
|
@@ -160,6 +175,8 @@ export interface BuyTurn {
|
|
|
160
175
|
store_login_expires_at: string | null;
|
|
161
176
|
/** The store that page signs in to. */
|
|
162
177
|
store_login_merchant_name: string | null;
|
|
178
|
+
/** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
|
|
179
|
+
pick: BuyPick | null;
|
|
163
180
|
/** The answer as Agentcard sent it. */
|
|
164
181
|
raw: Record<string, unknown>;
|
|
165
182
|
}
|
|
@@ -211,7 +228,7 @@ export interface VaultLink {
|
|
|
211
228
|
/**
|
|
212
229
|
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
213
230
|
* (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
|
|
214
|
-
* `user_id_mismatch`), one read from the shape of the answer
|
|
231
|
+
* `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
|
|
215
232
|
* (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
|
|
216
233
|
* `conversation_not_found`), or the client's (`unreachable`, `timeout`,
|
|
217
234
|
* `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
|
package/dist/client.js
CHANGED
|
@@ -20,7 +20,7 @@ const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'add_card', '
|
|
|
20
20
|
/**
|
|
21
21
|
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
22
22
|
* (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
|
|
23
|
-
* `user_id_mismatch`), one read from the shape of the answer
|
|
23
|
+
* `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
|
|
24
24
|
* (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
|
|
25
25
|
* `conversation_not_found`), or the client's (`unreachable`, `timeout`,
|
|
26
26
|
* `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
|
|
@@ -118,6 +118,7 @@ function catalogOf(v) {
|
|
|
118
118
|
const flight = flightOf(i.flight);
|
|
119
119
|
return {
|
|
120
120
|
id: str(i.id) ?? '',
|
|
121
|
+
ref: str(i.ref),
|
|
121
122
|
name: str(i.name),
|
|
122
123
|
priceCents: num(i.priceCents),
|
|
123
124
|
currency: str(i.currency),
|
|
@@ -161,6 +162,7 @@ export function turnOf(body) {
|
|
|
161
162
|
store_login_expires_at: str(body.store_login_expires_at),
|
|
162
163
|
store_login_merchant_name: str(body.store_login_merchant_name),
|
|
163
164
|
next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
|
|
165
|
+
pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
|
|
164
166
|
raw: body,
|
|
165
167
|
};
|
|
166
168
|
}
|