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