@agent-cards/commerce-sdk 0.1.0 → 0.2.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/LICENSE +1 -1
- package/README.md +26 -1
- package/dist/buy-tool.d.ts +13 -2
- package/dist/buy-tool.js +66 -9
- package/dist/client.d.ts +22 -0
- package/dist/client.js +28 -9
- package/dist/definition.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/messages.d.ts +48 -0
- package/dist/messages.js +206 -0
- package/dist/model-view.d.ts +11 -0
- package/dist/model-view.js +2 -1
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -35,4 +35,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
|
35
35
|
IMPLIED. IN NO EVENT SHALL AGENTCARD CORPORATION BE LIABLE FOR ANY CLAIM,
|
|
36
36
|
DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
|
37
37
|
|
|
38
|
-
Questions: legal@agentcard.
|
|
38
|
+
Questions: legal@agentcard.com
|
package/README.md
CHANGED
|
@@ -101,10 +101,35 @@ console.log(await result.getText());
|
|
|
101
101
|
|
|
102
102
|
A purchase needs a link at three moments: the shopper approves the purchase on an approval page, a new shopper adds a card on a Vault page, and a shopper signs in to a store's own account on its login page before Agentcard can buy there. Each link's `kind` says which: `approval`, `vault` or `store_login`, and a `store_login` link names the store in `store`. The tool hands each link to `sendLink` and waits for it, so the shopper holds the link before the model answers. The model reads `link_sent: true` and never the link itself, because a tool runner puts each answer straight into the model's context, and a model that holds a link writes it into the chat.
|
|
103
103
|
|
|
104
|
-
The tool also hands `sendLink` a `product` link for each product a search finds, up to
|
|
104
|
+
The tool also hands `sendLink` a `product` link for each product a search finds, up to three per answer. The link carries the product's page at its store in `url`, the product's name in `title`, its price in `price`, such as `$12.99`, its id in `productId`, and its picture in `imageUrl`, or null when the store sent none. Every price the tool writes, in a link or in the model's view, uses the currency Agentcard names for the cart or the product, in that currency's own minor units: `1331` is `$13.31` in USD, `CA$13.31` in CAD and `¥1,331` in JPY. A price whose currency Agentcard does not name shows with no symbol. The link arrives beside the reply, and you decide how to show it: as a card, in a list, or as a text with the URL. The model reads `product_links_sent` and never a URL, so a shopper who asks for links hears that the links are there. When the next answer in the conversation shows the same products, the tool sends none of their links again. A product link your `sendLink` throws on counts as not sent, and the call goes on.
|
|
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
|
+
## Send each paragraph as its own message
|
|
109
|
+
|
|
110
|
+
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:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { toMessages } from '@agent-cards/commerce-sdk';
|
|
114
|
+
|
|
115
|
+
for (const text of toMessages(reply)) await textShopper(shopper.id, text);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`result.messages` from `buy.answer` holds Agentcard's own answer cut the same way, links included, so your code sends it without cutting it again.
|
|
119
|
+
|
|
120
|
+
For a reply your model streams, `messageStream` hands over each paragraph the moment the blank line that ends it arrives, however the stream cuts the text. Push each piece of text, then call `end` for the last paragraph:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { messageStream } from '@agent-cards/commerce-sdk';
|
|
124
|
+
|
|
125
|
+
const paragraphs = messageStream({ onMessage: (text) => textShopper(shopper.id, text) });
|
|
126
|
+
stream.on('text', (delta) => paragraphs.push(delta));
|
|
127
|
+
await stream.done();
|
|
128
|
+
await paragraphs.end();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`stream` stands for your model's streamed answer. When `onMessage` returns a promise, the stream waits for it before it sends the next paragraph, so the shopper reads them in order, and `end` resolves once every paragraph went out. After a send fails, no later paragraph goes out, and `end` rejects with that error. A paragraph inside a list waits for the first characters of the next line, which say whether the list goes on. Start a new `messageStream` for each model message, so one message never runs into the next.
|
|
132
|
+
|
|
108
133
|
## Sign up a new shopper
|
|
109
134
|
|
|
110
135
|
A shopper who has not connected to your company yet gets a Vault link on their first call. The tool asks Agentcard for the link and hands it to `sendLink`, and the model reads `needs_card`. The shopper connects and adds a card on the Vault page, and the next call buys. Agentcard returns the same link while it has time left, so a shopper who asks twice gets one link.
|
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 BuyDeliveryAddress, type FetchLike } from './client.js';
|
|
25
|
+
import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type FetchLike } from './client.js';
|
|
26
26
|
import { type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolStandardSchema, type OpenAIBuyTool } from './definition.js';
|
|
27
27
|
import { type BuyToolModelView } from './model-view.js';
|
|
28
28
|
/**
|
|
@@ -40,7 +40,7 @@ export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'pl
|
|
|
40
40
|
* card. `approval`: the page where they approve this purchase. `store_login`:
|
|
41
41
|
* the page where they sign in to the store named in `store`, before Agentcard
|
|
42
42
|
* can buy there. `product`: a product's page at its store, with its `title`,
|
|
43
|
-
* `price` and `
|
|
43
|
+
* `price`, `productId` and `imageUrl`.
|
|
44
44
|
*/
|
|
45
45
|
export interface BuyToolLink {
|
|
46
46
|
kind: 'vault' | 'approval' | 'store_login' | 'product';
|
|
@@ -55,9 +55,12 @@ export interface BuyToolLink {
|
|
|
55
55
|
price?: string | null;
|
|
56
56
|
/** On a product link: Agentcard's id for the product. */
|
|
57
57
|
productId?: string;
|
|
58
|
+
/** On a product link: the product's picture, or null when Agentcard sent none. Show it beside the title. */
|
|
59
|
+
imageUrl?: string | null;
|
|
58
60
|
}
|
|
59
61
|
/** A product the last search found. */
|
|
60
62
|
export interface BuyToolProduct {
|
|
63
|
+
flight?: BuyFlightItinerary;
|
|
61
64
|
id: string;
|
|
62
65
|
name: string;
|
|
63
66
|
/** The price in the minor unit of `currency`. */
|
|
@@ -80,6 +83,12 @@ export interface BuyToolResult {
|
|
|
80
83
|
status: BuyToolStatus;
|
|
81
84
|
/** Agentcard's answer for the shopper, links included. */
|
|
82
85
|
reply: string;
|
|
86
|
+
/**
|
|
87
|
+
* The same answer as the separate messages a shopper reads one at a time, links included: each
|
|
88
|
+
* message Agentcard sent (the purchase answer's `messages`), cut at its blank lines by `toMessages`.
|
|
89
|
+
* An answer the tool wrote itself, such as `placing`, is `reply` cut the same way.
|
|
90
|
+
*/
|
|
91
|
+
messages: string[];
|
|
83
92
|
/** The cart on the table, or null. */
|
|
84
93
|
cart: BuyCart | null;
|
|
85
94
|
/** Every cart on the table, oldest first. */
|
|
@@ -97,6 +106,8 @@ export interface BuyToolResult {
|
|
|
97
106
|
order_id: string | null;
|
|
98
107
|
decline_code: string | null;
|
|
99
108
|
error_code: string | null;
|
|
109
|
+
/** The one step the shopper takes next, as Agentcard named it, or null. Only that step's link is in `links`. */
|
|
110
|
+
next_action: BuyNextAction | null;
|
|
100
111
|
/** Every cart hash in this answer. */
|
|
101
112
|
hashes: string[];
|
|
102
113
|
/** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
|
package/dist/buy-tool.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
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
|
+
import { toMessages } from './messages.js';
|
|
28
29
|
/** The pause before the one read that follows a placement Agentcard is still finishing. */
|
|
29
30
|
const PLACEMENT_WAIT_MS = 12_000;
|
|
30
31
|
/** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
|
|
@@ -63,6 +64,7 @@ function productsOf(turn) {
|
|
|
63
64
|
priceCents: item.priceCents,
|
|
64
65
|
currency: item.currency,
|
|
65
66
|
imageUrl: item.image_url,
|
|
67
|
+
...(item.flight ? { flight: item.flight } : {}),
|
|
66
68
|
store: item.store?.name ?? item.store?.id ?? turn.catalog?.merchant_name ?? null,
|
|
67
69
|
url: item.url ?? null,
|
|
68
70
|
linkSent: false,
|
|
@@ -79,6 +81,7 @@ function answerOf(turn) {
|
|
|
79
81
|
return {
|
|
80
82
|
status: 'needs_input',
|
|
81
83
|
reply: turn.reply,
|
|
84
|
+
messages: [],
|
|
82
85
|
cart: turn.cart,
|
|
83
86
|
carts: turn.carts,
|
|
84
87
|
products: productsOf(turn),
|
|
@@ -90,13 +93,60 @@ function answerOf(turn) {
|
|
|
90
93
|
order_id: turn.order_id,
|
|
91
94
|
decline_code: turn.decline_code,
|
|
92
95
|
error_code: turn.error_code,
|
|
96
|
+
next_action: turn.next_action,
|
|
93
97
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
94
98
|
envelope: turn.raw,
|
|
95
99
|
};
|
|
96
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The answer that waits on the shopper's card, when the turn carries Agentcard's add-card link: a
|
|
103
|
+
* checkout refused on an empty vault (`vault_unavailable`), or the agent made the link while it
|
|
104
|
+
* asked. The status beside it does not decide: the link is the one way forward either way, and the
|
|
105
|
+
* reply has every link taken out. Null when the turn carries no link, or placed an order.
|
|
106
|
+
*/
|
|
107
|
+
function needsCardOf(turn, answer) {
|
|
108
|
+
// Agentcard named another step first: the card waits until that one is done.
|
|
109
|
+
if (turn.next_action && turn.next_action !== 'add_card')
|
|
110
|
+
return null;
|
|
111
|
+
if (!turn.add_card_url || turn.status === 'order_placed' || turn.status === 'partially_placed' || turn.status === 'needs_approval')
|
|
112
|
+
return null;
|
|
113
|
+
const refused = turn.status === 'declined';
|
|
114
|
+
// A refusal with another code is about something other than a card, and keeps its own status.
|
|
115
|
+
if (refused && turn.decline_code !== 'vault_unavailable')
|
|
116
|
+
return null;
|
|
117
|
+
if (!refused && turn.decline_code && turn.decline_code !== 'vault_unavailable')
|
|
118
|
+
return null;
|
|
119
|
+
return { ...answer, status: 'needs_card', links: [...answer.links, { kind: 'vault', url: turn.add_card_url, expiresAt: turn.add_card_expires_at }] };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The answer with only the link of the step the shopper takes now, when Agentcard names one
|
|
123
|
+
* (`next_action`): the Vault link waits for `add_card`, the approval link for `approve`. A store login
|
|
124
|
+
* link and product pages are not steps, so they stay. An answer that names no step keeps every link.
|
|
125
|
+
*/
|
|
126
|
+
function onlyNextStepLinks(result, next) {
|
|
127
|
+
if (!next)
|
|
128
|
+
return result;
|
|
129
|
+
const links = result.links.filter((link) => (link.kind === 'vault' ? next === 'add_card' : link.kind === 'approval' ? next === 'approve' : true));
|
|
130
|
+
return links.length === result.links.length ? result : { ...result, links };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The answer's `messages`, read once its `reply` is final: the messages Agentcard sent while the reply
|
|
134
|
+
* is still Agentcard's own, else the reply the tool wrote, each cut at its blank lines.
|
|
135
|
+
*/
|
|
136
|
+
function withMessages(result, turn) {
|
|
137
|
+
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
138
|
+
return { ...result, messages: sent.flatMap(toMessages) };
|
|
139
|
+
}
|
|
97
140
|
/** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
|
|
98
141
|
function resultOfTurn(turn) {
|
|
142
|
+
return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
|
|
143
|
+
}
|
|
144
|
+
/** A purchase answer, read for its status, with every link the answer carries. */
|
|
145
|
+
function resultOfStatus(turn) {
|
|
99
146
|
const answer = answerOf(turn);
|
|
147
|
+
const card = needsCardOf(turn, answer);
|
|
148
|
+
if (card)
|
|
149
|
+
return card;
|
|
100
150
|
switch (turn.status) {
|
|
101
151
|
case 'order_placed':
|
|
102
152
|
case 'partially_placed':
|
|
@@ -116,10 +166,6 @@ function resultOfTurn(turn) {
|
|
|
116
166
|
shopper_need_not_reply: turn.placement_announced === true,
|
|
117
167
|
};
|
|
118
168
|
}
|
|
119
|
-
// A shopper with no card gets the add-card link checkout made. Every other reason for this code is not about a card.
|
|
120
|
-
if (turn.decline_code === 'vault_unavailable' && turn.add_card_url) {
|
|
121
|
-
return { ...answer, status: 'needs_card', links: [...answer.links, { kind: 'vault', url: turn.add_card_url, expiresAt: turn.add_card_expires_at }] };
|
|
122
|
-
}
|
|
123
169
|
return { ...answer, status: 'declined' };
|
|
124
170
|
default:
|
|
125
171
|
return answer;
|
|
@@ -132,6 +178,7 @@ function resultOfRefusal(error) {
|
|
|
132
178
|
const answer = (reply, carts) => ({
|
|
133
179
|
status: 'needs_input',
|
|
134
180
|
reply,
|
|
181
|
+
messages: [],
|
|
135
182
|
cart: carts.at(-1) ?? null,
|
|
136
183
|
carts,
|
|
137
184
|
products: [],
|
|
@@ -143,6 +190,7 @@ function resultOfRefusal(error) {
|
|
|
143
190
|
order_id: null,
|
|
144
191
|
decline_code: null,
|
|
145
192
|
error_code: error.code,
|
|
193
|
+
next_action: null,
|
|
146
194
|
hashes: hashesOf(null, carts),
|
|
147
195
|
envelope: error.body,
|
|
148
196
|
});
|
|
@@ -161,6 +209,7 @@ function needsCard(link, body) {
|
|
|
161
209
|
return {
|
|
162
210
|
status: 'needs_card',
|
|
163
211
|
reply: CARD_NEEDED,
|
|
212
|
+
messages: [],
|
|
164
213
|
cart: null,
|
|
165
214
|
carts: [],
|
|
166
215
|
products: [],
|
|
@@ -172,6 +221,7 @@ function needsCard(link, body) {
|
|
|
172
221
|
order_id: null,
|
|
173
222
|
decline_code: null,
|
|
174
223
|
error_code: 'user_not_connected',
|
|
224
|
+
next_action: 'add_card',
|
|
175
225
|
hashes: [],
|
|
176
226
|
envelope: body,
|
|
177
227
|
};
|
|
@@ -267,6 +317,7 @@ export function buyTool(options) {
|
|
|
267
317
|
status: 'order_placed',
|
|
268
318
|
reply: `The ${order.merchant || 'store'} order went through, order number ${order.orderId}.`,
|
|
269
319
|
links: [],
|
|
320
|
+
next_action: null,
|
|
270
321
|
order_id: order.orderId,
|
|
271
322
|
conversation_id: conversationId,
|
|
272
323
|
};
|
|
@@ -280,6 +331,7 @@ export function buyTool(options) {
|
|
|
280
331
|
links: [],
|
|
281
332
|
order_id: order.orderId,
|
|
282
333
|
decline_code: `order_${order.status}`,
|
|
334
|
+
next_action: null,
|
|
283
335
|
shopper_need_not_reply: false,
|
|
284
336
|
conversation_id: conversationId,
|
|
285
337
|
};
|
|
@@ -291,6 +343,7 @@ export function buyTool(options) {
|
|
|
291
343
|
reply: refusal.message || `${NOT_PLACED} The ${refusal.merchant || 'store'} answered ${refusal.code}.`,
|
|
292
344
|
links: [],
|
|
293
345
|
decline_code: refusal.code,
|
|
346
|
+
next_action: null,
|
|
294
347
|
shopper_need_not_reply: false,
|
|
295
348
|
});
|
|
296
349
|
/**
|
|
@@ -306,7 +359,7 @@ export function buyTool(options) {
|
|
|
306
359
|
const refusal = conversation && checkoutRefusal(conversation);
|
|
307
360
|
if (refusal)
|
|
308
361
|
return refusedCheckout(base, refusal);
|
|
309
|
-
return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
|
|
362
|
+
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
|
|
310
363
|
};
|
|
311
364
|
/**
|
|
312
365
|
* What a finished turn's checkout left besides an order, which `placedOrderOf`
|
|
@@ -324,6 +377,7 @@ export function buyTool(options) {
|
|
|
324
377
|
reply: last.message || CARD_NEEDED,
|
|
325
378
|
links: [{ kind: 'vault', url: last.add_card_url, expiresAt: last.add_card_expires_at }],
|
|
326
379
|
decline_code: last.decline_code,
|
|
380
|
+
next_action: 'add_card',
|
|
327
381
|
shopper_need_not_reply: false,
|
|
328
382
|
envelope: conversation.raw,
|
|
329
383
|
};
|
|
@@ -335,13 +389,14 @@ export function buyTool(options) {
|
|
|
335
389
|
}
|
|
336
390
|
const announced = last.placement_announced === true;
|
|
337
391
|
if (last.approval_in_progress === true)
|
|
338
|
-
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], shopper_need_not_reply: announced };
|
|
392
|
+
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null, shopper_need_not_reply: announced };
|
|
339
393
|
return {
|
|
340
394
|
...base,
|
|
341
395
|
status: 'approval_sent',
|
|
342
396
|
reply: APPROVAL_WAITING,
|
|
343
397
|
links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
|
|
344
398
|
approval_expires_at: last.approval_expires_at,
|
|
399
|
+
next_action: 'approve',
|
|
345
400
|
shopper_need_not_reply: announced,
|
|
346
401
|
envelope: conversation.raw,
|
|
347
402
|
};
|
|
@@ -399,6 +454,8 @@ export function buyTool(options) {
|
|
|
399
454
|
carts: conversation.carts,
|
|
400
455
|
hashes: conversation.carts.map((c) => c.hash),
|
|
401
456
|
error_code: newest ? null : base.error_code,
|
|
457
|
+
// A read replaced the answer the step was named for, and names no step of its own: the reply asks.
|
|
458
|
+
next_action: null,
|
|
402
459
|
envelope: conversation.raw,
|
|
403
460
|
};
|
|
404
461
|
if (!conversation.turn_in_progress)
|
|
@@ -408,7 +465,7 @@ export function buyTool(options) {
|
|
|
408
465
|
}
|
|
409
466
|
}
|
|
410
467
|
if (placing)
|
|
411
|
-
return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
|
|
468
|
+
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
|
|
412
469
|
// A minute that ends with the turn still running returns what the last read showed, a new cart as
|
|
413
470
|
// ready although no read handed it back: nothing better exists by then, and a confirm that misses it
|
|
414
471
|
// is refused, never placed on another cart.
|
|
@@ -479,7 +536,7 @@ export function buyTool(options) {
|
|
|
479
536
|
for (const [url, product] of pages) {
|
|
480
537
|
if (!before.has(url)) {
|
|
481
538
|
try {
|
|
482
|
-
await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, expiresAt: null });
|
|
539
|
+
await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, imageUrl: product.imageUrl, expiresAt: null });
|
|
483
540
|
}
|
|
484
541
|
catch {
|
|
485
542
|
continue;
|
|
@@ -530,7 +587,7 @@ export function buyTool(options) {
|
|
|
530
587
|
catch (error) {
|
|
531
588
|
refusal = error;
|
|
532
589
|
}
|
|
533
|
-
return deliver(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt));
|
|
590
|
+
return deliver(withMessages(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt), turn));
|
|
534
591
|
};
|
|
535
592
|
/** Checks the model's input, with every link taken out of a refusal. */
|
|
536
593
|
const parse = (input) => {
|
package/dist/client.d.ts
CHANGED
|
@@ -87,7 +87,19 @@ export interface BuyCart {
|
|
|
87
87
|
hash: string;
|
|
88
88
|
}
|
|
89
89
|
/** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
|
|
90
|
+
/** Supplier facts only. Missing facts stay null; fare cabin does not establish segment cabins. */
|
|
91
|
+
export interface BuyFlightItinerary {
|
|
92
|
+
fareCabinClass?: string | null;
|
|
93
|
+
legs: Array<{
|
|
94
|
+
origin: string | null;
|
|
95
|
+
destination: string | null;
|
|
96
|
+
departureAt: string | null;
|
|
97
|
+
arrivalAt: string | null;
|
|
98
|
+
cabinClasses: Array<string | null>;
|
|
99
|
+
}>;
|
|
100
|
+
}
|
|
90
101
|
export interface BuyCatalogItem {
|
|
102
|
+
flight?: BuyFlightItinerary;
|
|
91
103
|
id: string;
|
|
92
104
|
name: string | null;
|
|
93
105
|
priceCents: number | null;
|
|
@@ -109,6 +121,14 @@ export interface BuyPaymentSource {
|
|
|
109
121
|
brand: string | null;
|
|
110
122
|
last4: string | null;
|
|
111
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The one thing the shopper does next, as Agentcard names it, in the order a purchase asks for them:
|
|
126
|
+
* `shipping_details` (a shipping address and phone), `guest_details` (a name, email and phone for a
|
|
127
|
+
* booking), `add_card` (a card in their Vault), `confirm` (their yes to the cart), `verification_code`
|
|
128
|
+
* (the code a booking texted them), then `approve` (the approval link). A value this version does
|
|
129
|
+
* not know reads as null.
|
|
130
|
+
*/
|
|
131
|
+
export type BuyNextAction = 'shipping_details' | 'guest_details' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
|
|
112
132
|
/** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
|
|
113
133
|
export interface BuyTurn {
|
|
114
134
|
conversation_id: string;
|
|
@@ -133,6 +153,8 @@ export interface BuyTurn {
|
|
|
133
153
|
placement_announced: boolean | null;
|
|
134
154
|
approval_expires_at: string | null;
|
|
135
155
|
approval_in_progress: boolean | null;
|
|
156
|
+
/** The one step the shopper takes next, or null when nothing waits on them or Agentcard named none. */
|
|
157
|
+
next_action: BuyNextAction | null;
|
|
136
158
|
/** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
|
|
137
159
|
store_login_url: string | null;
|
|
138
160
|
store_login_expires_at: string | null;
|
package/dist/client.js
CHANGED
|
@@ -16,6 +16,7 @@ export const BUY_TURN_TIMEOUT_MS = 130_000;
|
|
|
16
16
|
const CALL_TIMEOUT_MS = 30_000;
|
|
17
17
|
/** A token is renewed this long before it expires, so it never expires mid-call. */
|
|
18
18
|
const RENEW_EARLY_MS = 60_000;
|
|
19
|
+
const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'add_card', 'confirm', 'verification_code', 'approve']);
|
|
19
20
|
/**
|
|
20
21
|
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
21
22
|
* (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
|
|
@@ -97,18 +98,35 @@ function cartOf(v) {
|
|
|
97
98
|
function cartsOf(v) {
|
|
98
99
|
return Array.isArray(v) ? v.map(cartOf).filter((c) => c !== null) : [];
|
|
99
100
|
}
|
|
101
|
+
function flightOf(value) {
|
|
102
|
+
if (!isRecord(value) || !Array.isArray(value.legs))
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
...(Object.hasOwn(value, 'fareCabinClass') ? { fareCabinClass: str(value.fareCabinClass) } : {}),
|
|
106
|
+
legs: value.legs.map(value => {
|
|
107
|
+
const leg = isRecord(value) ? value : {};
|
|
108
|
+
return { origin: str(leg.origin), destination: str(leg.destination),
|
|
109
|
+
departureAt: str(leg.departureAt), arrivalAt: str(leg.arrivalAt),
|
|
110
|
+
cabinClasses: Array.isArray(leg.cabinClasses) ? leg.cabinClasses.map(str) : [] };
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
100
114
|
function catalogOf(v) {
|
|
101
115
|
if (!isRecord(v))
|
|
102
116
|
return null;
|
|
103
|
-
const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => {
|
|
118
|
+
const flight = flightOf(i.flight);
|
|
119
|
+
return {
|
|
120
|
+
id: str(i.id) ?? '',
|
|
121
|
+
name: str(i.name),
|
|
122
|
+
priceCents: num(i.priceCents),
|
|
123
|
+
currency: str(i.currency),
|
|
124
|
+
image_url: str(i.image_url),
|
|
125
|
+
...(flight ? { flight } : {}),
|
|
126
|
+
store: storeOf(i.store),
|
|
127
|
+
url: pageUrl(i.url),
|
|
128
|
+
};
|
|
129
|
+
}) : [];
|
|
112
130
|
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
|
|
113
131
|
}
|
|
114
132
|
function paymentSourceOf(v) {
|
|
@@ -142,6 +160,7 @@ export function turnOf(body) {
|
|
|
142
160
|
store_login_url: str(body.store_login_url),
|
|
143
161
|
store_login_expires_at: str(body.store_login_expires_at),
|
|
144
162
|
store_login_merchant_name: str(body.store_login_merchant_name),
|
|
163
|
+
next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
|
|
145
164
|
raw: body,
|
|
146
165
|
};
|
|
147
166
|
}
|
package/dist/definition.js
CHANGED
|
@@ -11,6 +11,7 @@ export const BUY_TOOL_DESCRIPTION = [
|
|
|
11
11
|
"Put the shopper's own words in `ask`.",
|
|
12
12
|
'Tell the shopper what `reply` says, in your own voice.',
|
|
13
13
|
'Show the shopper the cart before you confirm it.',
|
|
14
|
+
'Ask the shopper for exactly one thing per message: the step `next_action` names, even when `reply` lists more. Never mention a later step, and never ask for the next one until the shopper has done this one.',
|
|
14
15
|
'Set `confirm` to true only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
|
|
15
16
|
'Never write a link.',
|
|
16
17
|
'Each link reaches the shopper on its own.',
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
|
|
2
2
|
export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
|
|
3
3
|
export { purchaseAgentWebhook, type PurchaseAgentWebhookOptions, type WebhookRequest, type WebhookResponse, } from './handler.js';
|
|
4
|
-
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, } from './client.js';
|
|
4
|
+
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type FetchLike, 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, } from './client.js';
|
|
5
5
|
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
|
|
6
|
+
export { toMessages, messageStream, type MessageStream, type MessageStreamOptions, } from './messages.js';
|
|
6
7
|
export { buyTool, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolProduct, } from './buy-tool.js';
|
|
7
8
|
export { forModel, stripLinks, type BuyToolModelView, type BuyToolModelCart, type BuyToolModelProduct, } from './model-view.js';
|
|
8
9
|
export { toAnthropicTool, toOpenRouterTool, type AnthropicBuyTool, type OpenRouterToolkit, type ZodLike, } from './adapters.js';
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { readPurchaseMessage, } from './message.js';
|
|
|
3
3
|
export { purchaseAgentWebhook, } from './handler.js';
|
|
4
4
|
export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, } from './client.js';
|
|
5
5
|
export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, } from './definition.js';
|
|
6
|
+
export { toMessages, messageStream, } from './messages.js';
|
|
6
7
|
export { buyTool, } from './buy-tool.js';
|
|
7
8
|
export { forModel, stripLinks, } from './model-view.js';
|
|
8
9
|
export { toAnthropicTool, toOpenRouterTool, } from './adapters.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
|
|
3
|
+
* own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
|
|
4
|
+
* paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
|
|
5
|
+
* items a blank line separates.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
|
|
9
|
+
* out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
|
|
10
|
+
* lines it holds, and so does a list whose items blank lines separate.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function toMessages(text: string): string[];
|
|
17
|
+
/** What `messageStream` hands back: push each piece of text as it arrives, then end once it stops. */
|
|
18
|
+
export interface MessageStream {
|
|
19
|
+
/** Adds the next piece of text. Each message the piece completes goes to `onMessage` in order, after the one before it settled. */
|
|
20
|
+
push(delta: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* The text is whole: the last message goes to `onMessage`. Resolves once every `onMessage` call
|
|
23
|
+
* settled, and rejects with the first error one threw or rejected with. A push after `end` throws.
|
|
24
|
+
*/
|
|
25
|
+
end(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export interface MessageStreamOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Hears each message, once and in order, as soon as the text after it ends it. A returned promise
|
|
30
|
+
* is awaited before the next message goes out, so an asynchronous send never delivers out of order.
|
|
31
|
+
* After a send fails, no later message is sent, and `end` rejects with the failure.
|
|
32
|
+
*/
|
|
33
|
+
onMessage: (message: string) => void | Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
|
|
37
|
+
* `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
|
|
38
|
+
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
39
|
+
* whether the list goes on. `end` hands over the last message and waits for every send.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
|
|
43
|
+
* stream.on('text', (delta) => paragraphs.push(delta));
|
|
44
|
+
* await stream.done();
|
|
45
|
+
* await paragraphs.end();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function messageStream(options: MessageStreamOptions): MessageStream;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text cut into the messages a shopper reads one at a time. A chat app sends each paragraph as its
|
|
3
|
+
* own message, so a reply written as three paragraphs arrives as three texts. A blank line ends a
|
|
4
|
+
* paragraph, with two exceptions that keep a block whole: a fenced code block, and a list whose
|
|
5
|
+
* items a blank line separates.
|
|
6
|
+
*/
|
|
7
|
+
/** A line holding nothing but spaces or tabs. */
|
|
8
|
+
const BLANK = /^[ \t]*$/;
|
|
9
|
+
/** A list item: `-`, `*` or `+`, or a number followed by `.` or `)`, then a space or the line's end. */
|
|
10
|
+
const LIST_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
|
|
11
|
+
/** A list item whose marker a space already follows, the only kind a line still being written is sure to stay. */
|
|
12
|
+
const LIST_ITEM_SPACED = /^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]/;
|
|
13
|
+
/** A line indented under a list item: two spaces or a tab, then text. */
|
|
14
|
+
const INDENTED = /^(?: {2,}|\t)\S/;
|
|
15
|
+
/** The start of a line that can still grow into a list item or an indented line once more text arrives. */
|
|
16
|
+
const MAYBE_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)]?)?$|^[ \t]*$/;
|
|
17
|
+
/** A code fence: three or more backticks or tildes, indented by at most three spaces. */
|
|
18
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
19
|
+
/** How the line after a blank line inside a list reads: it continues the list, it ends it, or it is too short to tell. */
|
|
20
|
+
function afterListBlank(line, complete) {
|
|
21
|
+
// `1.` alone is an item once the line ends there, and the start of `1.5 cups` while it is still arriving.
|
|
22
|
+
if ((complete ? LIST_ITEM : LIST_ITEM_SPACED).test(line) || INDENTED.test(line))
|
|
23
|
+
return 'continues';
|
|
24
|
+
if (!complete && MAYBE_ITEM.test(line))
|
|
25
|
+
return 'unknown';
|
|
26
|
+
return 'ends';
|
|
27
|
+
}
|
|
28
|
+
/** True when `line` closes the fence `open` opened: the same character, at least as many times, and nothing after. */
|
|
29
|
+
function closesFence(line, open) {
|
|
30
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
31
|
+
return !!match && match[1][0] === open[0] && match[1].length >= open.length;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reads text line by line and closes each message once nothing later can change it. It keeps only
|
|
35
|
+
* the message being built, so text fed in pieces costs what the text costs once.
|
|
36
|
+
*/
|
|
37
|
+
class Scanner {
|
|
38
|
+
onClose;
|
|
39
|
+
current = [];
|
|
40
|
+
fence = null;
|
|
41
|
+
inList = false;
|
|
42
|
+
blankInList = false;
|
|
43
|
+
constructor(onClose) {
|
|
44
|
+
this.onClose = onClose;
|
|
45
|
+
}
|
|
46
|
+
close() {
|
|
47
|
+
const message = this.current.join('\n').trim();
|
|
48
|
+
this.current = [];
|
|
49
|
+
this.inList = false;
|
|
50
|
+
this.blankInList = false;
|
|
51
|
+
if (message)
|
|
52
|
+
this.onClose(message);
|
|
53
|
+
}
|
|
54
|
+
/** One whole line. */
|
|
55
|
+
line(line) {
|
|
56
|
+
if (this.fence !== null) {
|
|
57
|
+
this.current.push(line);
|
|
58
|
+
if (closesFence(line, this.fence))
|
|
59
|
+
this.fence = null;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (BLANK.test(line)) {
|
|
63
|
+
if (this.current.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
// A blank line ends the paragraph, unless it sits between two items of one list.
|
|
66
|
+
if (this.inList)
|
|
67
|
+
this.blankInList = true;
|
|
68
|
+
else
|
|
69
|
+
this.close();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (this.blankInList) {
|
|
73
|
+
if (afterListBlank(line, true) === 'ends')
|
|
74
|
+
this.close();
|
|
75
|
+
else
|
|
76
|
+
this.current.push('');
|
|
77
|
+
this.blankInList = false;
|
|
78
|
+
}
|
|
79
|
+
this.current.push(line);
|
|
80
|
+
if (LIST_ITEM.test(line))
|
|
81
|
+
this.inList = true;
|
|
82
|
+
else if (this.current.length === 1)
|
|
83
|
+
this.inList = false;
|
|
84
|
+
const open = FENCE.exec(line);
|
|
85
|
+
if (open)
|
|
86
|
+
this.fence = open[1];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The start of a line still being written. After a blank line inside a list, its first characters
|
|
90
|
+
* can already say the list ended, and the list goes out as soon as they do. Only the start counts:
|
|
91
|
+
* no marker, indent or number runs past 16 characters.
|
|
92
|
+
*/
|
|
93
|
+
partial(start) {
|
|
94
|
+
if (this.fence === null && this.blankInList && afterListBlank(start.slice(0, 16), false) === 'ends')
|
|
95
|
+
this.close();
|
|
96
|
+
}
|
|
97
|
+
/** The text is whole: the message being built closes. */
|
|
98
|
+
end() {
|
|
99
|
+
this.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The text as separate messages, in order: one per paragraph, each trimmed, with empty ones left
|
|
104
|
+
* out. One or more blank lines end a paragraph. A fenced code block stays one message whatever blank
|
|
105
|
+
* lines it holds, and so does a list whose items blank lines separate.
|
|
106
|
+
*
|
|
107
|
+
* ```ts
|
|
108
|
+
* toMessages('Found two.\n\nWant the first one?'); // ['Found two.', 'Want the first one?']
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function toMessages(text) {
|
|
112
|
+
const messages = [];
|
|
113
|
+
const scanner = new Scanner((message) => messages.push(message));
|
|
114
|
+
for (const line of String(text ?? '').replace(/\r\n?/g, '\n').split('\n'))
|
|
115
|
+
scanner.line(line);
|
|
116
|
+
scanner.end();
|
|
117
|
+
return messages;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Cuts streamed text into the messages `toMessages` finds, as it arrives. Each paragraph goes to
|
|
121
|
+
* `onMessage` the moment the blank line that ends it arrives, whatever the pieces split it into. A
|
|
122
|
+
* paragraph inside a list waits for the first characters of the line after the blank, which say
|
|
123
|
+
* whether the list goes on. `end` hands over the last message and waits for every send.
|
|
124
|
+
*
|
|
125
|
+
* ```ts
|
|
126
|
+
* const paragraphs = messageStream({ onMessage: (text) => sendToShopper(text) });
|
|
127
|
+
* stream.on('text', (delta) => paragraphs.push(delta));
|
|
128
|
+
* await stream.done();
|
|
129
|
+
* await paragraphs.end();
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function messageStream(options) {
|
|
133
|
+
const onMessage = options?.onMessage;
|
|
134
|
+
if (typeof onMessage !== 'function')
|
|
135
|
+
throw new TypeError('Pass onMessage as a function that takes each message.');
|
|
136
|
+
// The line still being written. A trailing \r waits here too, in case its \n is in the next piece.
|
|
137
|
+
let open = '';
|
|
138
|
+
let ended = false;
|
|
139
|
+
let failure = null;
|
|
140
|
+
// Messages waiting for the send before them. A send that returns no promise goes out at once.
|
|
141
|
+
const queue = [];
|
|
142
|
+
let running = null;
|
|
143
|
+
const drain = () => {
|
|
144
|
+
while (queue.length > 0 && !failure) {
|
|
145
|
+
const message = queue.shift();
|
|
146
|
+
let sent;
|
|
147
|
+
try {
|
|
148
|
+
sent = onMessage(message);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
failure = { error };
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (sent && typeof sent.then === 'function') {
|
|
155
|
+
return Promise.resolve(sent).then(() => drain() ?? undefined, (error) => {
|
|
156
|
+
failure = { error };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
};
|
|
162
|
+
const kick = () => {
|
|
163
|
+
if (running)
|
|
164
|
+
return;
|
|
165
|
+
const pending = drain();
|
|
166
|
+
if (pending) {
|
|
167
|
+
running = pending.then(() => {
|
|
168
|
+
running = null;
|
|
169
|
+
kick();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const scanner = new Scanner((message) => {
|
|
174
|
+
queue.push(message);
|
|
175
|
+
kick();
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
push(delta) {
|
|
179
|
+
if (ended)
|
|
180
|
+
throw new Error('This message stream has ended. Start a new one for the next text.');
|
|
181
|
+
if (typeof delta !== 'string' || delta === '')
|
|
182
|
+
return;
|
|
183
|
+
const text = open + delta;
|
|
184
|
+
const cut = text.endsWith('\r') ? text.length - 1 : text.length;
|
|
185
|
+
const lines = text.slice(0, cut).replace(/\r\n?/g, '\n').split('\n');
|
|
186
|
+
open = (lines.pop() ?? '') + text.slice(cut);
|
|
187
|
+
for (const line of lines)
|
|
188
|
+
scanner.line(line);
|
|
189
|
+
// A held \r is a line ending still arriving, not text that could end a list.
|
|
190
|
+
scanner.partial(open.endsWith('\r') ? open.slice(0, -1) : open);
|
|
191
|
+
},
|
|
192
|
+
async end() {
|
|
193
|
+
if (!ended) {
|
|
194
|
+
ended = true;
|
|
195
|
+
for (const line of open.replace(/\r\n?/g, '\n').split('\n'))
|
|
196
|
+
scanner.line(line);
|
|
197
|
+
open = '';
|
|
198
|
+
scanner.end();
|
|
199
|
+
}
|
|
200
|
+
while (running)
|
|
201
|
+
await running;
|
|
202
|
+
if (failure)
|
|
203
|
+
throw failure.error;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/dist/model-view.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model reads: the buy tool's answer with every link taken out. A
|
|
3
|
+
* tool runner puts a tool's result straight into the model's context, and a
|
|
4
|
+
* model that holds a link writes it into the chat. So a link never enters
|
|
5
|
+
* this view: `sendLink` delivers it, and the model reads `link_sent` for the
|
|
6
|
+
* Vault and approval links, and `link_sent` on each product and
|
|
7
|
+
* `product_links_sent` for the products' store pages.
|
|
8
|
+
*/
|
|
9
|
+
import type { BuyNextAction } from './client.js';
|
|
1
10
|
import type { BuyToolProduct, BuyToolResult, BuyToolStatus } from './buy-tool.js';
|
|
2
11
|
export interface BuyToolModelCart {
|
|
3
12
|
store: string;
|
|
@@ -19,6 +28,8 @@ export interface BuyToolModelProduct {
|
|
|
19
28
|
/** The buy tool's answer as the model reads it. */
|
|
20
29
|
export interface BuyToolModelView {
|
|
21
30
|
status: BuyToolStatus;
|
|
31
|
+
/** The one thing to ask the shopper for in this message, or null when nothing waits on them. Never ask for a later step. */
|
|
32
|
+
next_action: BuyNextAction | null;
|
|
22
33
|
/** Agentcard's answer for the shopper, with every link taken out. */
|
|
23
34
|
reply: string;
|
|
24
35
|
/** The newest cart on the table. */
|
package/dist/model-view.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
2
|
-
/**
|
|
2
|
+
/** At most twenty products reach the model. Agentcard's /buy answer lists three, so this cap holds only for an older answer. */
|
|
3
3
|
const MAX_PRODUCTS = 20;
|
|
4
4
|
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
|
5
5
|
const SCHEME_URL = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`)\]]*/gi;
|
|
@@ -88,6 +88,7 @@ export function forModel(result) {
|
|
|
88
88
|
const held = (product) => product.linkSent === true && typeof product.url === 'string' && product.url !== '';
|
|
89
89
|
return {
|
|
90
90
|
status,
|
|
91
|
+
next_action: result.next_action ?? null,
|
|
91
92
|
reply: stripLinks(result.reply),
|
|
92
93
|
cart: result.cart ? cartView(result.cart) : null,
|
|
93
94
|
carts: (result.carts ?? []).map(cartView),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/commerce-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Give your agent one tool that buys for your shoppers at real stores, and hear what happened to each purchase from a signed webhook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|