@agent-cards/commerce-sdk 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +111 -4
- package/dist/ack.d.ts +35 -0
- package/dist/ack.js +118 -0
- package/dist/adapters.d.ts +4 -1
- package/dist/adapters.js +1 -0
- package/dist/buy-tool.d.ts +29 -2
- package/dist/buy-tool.js +174 -14
- package/dist/client.d.ts +40 -1
- package/dist/client.js +31 -10
- package/dist/definition.d.ts +15 -4
- package/dist/definition.js +20 -7
- package/dist/handler.d.ts +58 -5
- package/dist/handler.js +207 -46
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/message.d.ts +3 -1
- package/dist/messages.d.ts +48 -0
- package/dist/messages.js +206 -0
- package/dist/model-view.d.ts +24 -1
- package/dist/model-view.js +15 -3
- package/dist/shopper-message.d.ts +86 -0
- package/dist/shopper-message.js +175 -0
- package/package.json +2 -2
package/dist/buy-tool.js
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
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';
|
|
29
|
+
import { ackAt, isNewRequest, nextTemplate } from './ack.js';
|
|
28
30
|
/** The pause before the one read that follows a placement Agentcard is still finishing. */
|
|
29
31
|
const PLACEMENT_WAIT_MS = 12_000;
|
|
30
32
|
/** After a turn that outlived Agentcard's budget: one read every 5 seconds, for at most a minute. */
|
|
@@ -40,6 +42,10 @@ const RESUME_SKEW_MS = 2 * 60_000;
|
|
|
40
42
|
const STOPPED_ORDER = new Set(['failed', 'cancelled']);
|
|
41
43
|
/** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
|
|
42
44
|
const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
|
|
45
|
+
/** How long Agentcard has to answer before the instant reply goes out. */
|
|
46
|
+
const ACK_DELAY_MS = 1_500;
|
|
47
|
+
/** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
|
|
48
|
+
const SAME_TURN_MS = 15_000;
|
|
43
49
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
44
50
|
const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
|
|
45
51
|
const STILL_PLACING = 'The shopper approved, and the order is still going through.';
|
|
@@ -51,6 +57,18 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
|
|
|
51
57
|
const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
|
|
52
58
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
53
59
|
const ADDRESS_CHANGED = 'The delivery address changed since this cart was built. Ask for the cart again, and Agentcard prices it for the new address.';
|
|
60
|
+
const PICK_GONE = "The product the shopper picked is no longer among the products Agentcard found, so nothing was added. Call this tool again with the shopper's words and product_id null, and Agentcard searches again.";
|
|
61
|
+
/**
|
|
62
|
+
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
63
|
+
* the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
|
|
64
|
+
* its store page, as its page. A pick already in the cart, or none, stays as it was.
|
|
65
|
+
*/
|
|
66
|
+
function pickAfter(pick, carts) {
|
|
67
|
+
if (!pick || pick.in_cart)
|
|
68
|
+
return pick;
|
|
69
|
+
const held = carts.some((c) => c.items.some((line) => line.product_id === pick.product_id || line.url === pick.product_id));
|
|
70
|
+
return held ? { ...pick, in_cart: true } : pick;
|
|
71
|
+
}
|
|
54
72
|
function hashesOf(cart, carts) {
|
|
55
73
|
return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
|
|
56
74
|
}
|
|
@@ -59,10 +77,12 @@ function productsOf(turn) {
|
|
|
59
77
|
.filter((item) => item.name)
|
|
60
78
|
.map((item) => ({
|
|
61
79
|
id: item.id,
|
|
80
|
+
ref: item.ref,
|
|
62
81
|
name: item.name,
|
|
63
82
|
priceCents: item.priceCents,
|
|
64
83
|
currency: item.currency,
|
|
65
84
|
imageUrl: item.image_url,
|
|
85
|
+
...(item.flight ? { flight: item.flight } : {}),
|
|
66
86
|
store: item.store?.name ?? item.store?.id ?? turn.catalog?.merchant_name ?? null,
|
|
67
87
|
url: item.url ?? null,
|
|
68
88
|
linkSent: false,
|
|
@@ -79,6 +99,7 @@ function answerOf(turn) {
|
|
|
79
99
|
return {
|
|
80
100
|
status: 'needs_input',
|
|
81
101
|
reply: turn.reply,
|
|
102
|
+
messages: [],
|
|
82
103
|
cart: turn.cart,
|
|
83
104
|
carts: turn.carts,
|
|
84
105
|
products: productsOf(turn),
|
|
@@ -90,13 +111,61 @@ function answerOf(turn) {
|
|
|
90
111
|
order_id: turn.order_id,
|
|
91
112
|
decline_code: turn.decline_code,
|
|
92
113
|
error_code: turn.error_code,
|
|
114
|
+
next_action: turn.next_action,
|
|
115
|
+
pick: turn.pick,
|
|
93
116
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
94
117
|
envelope: turn.raw,
|
|
95
118
|
};
|
|
96
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The answer that waits on the shopper's card, when the turn carries Agentcard's add-card link: a
|
|
122
|
+
* checkout refused on an empty vault (`vault_unavailable`), or the agent made the link while it
|
|
123
|
+
* asked. The status beside it does not decide: the link is the one way forward either way, and the
|
|
124
|
+
* reply has every link taken out. Null when the turn carries no link, or placed an order.
|
|
125
|
+
*/
|
|
126
|
+
function needsCardOf(turn, answer) {
|
|
127
|
+
// Agentcard named another step first: the card waits until that one is done.
|
|
128
|
+
if (turn.next_action && turn.next_action !== 'add_card')
|
|
129
|
+
return null;
|
|
130
|
+
if (!turn.add_card_url || turn.status === 'order_placed' || turn.status === 'partially_placed' || turn.status === 'needs_approval')
|
|
131
|
+
return null;
|
|
132
|
+
const refused = turn.status === 'declined';
|
|
133
|
+
// A refusal with another code is about something other than a card, and keeps its own status.
|
|
134
|
+
if (refused && turn.decline_code !== 'vault_unavailable')
|
|
135
|
+
return null;
|
|
136
|
+
if (!refused && turn.decline_code && turn.decline_code !== 'vault_unavailable')
|
|
137
|
+
return null;
|
|
138
|
+
return { ...answer, status: 'needs_card', links: [...answer.links, { kind: 'vault', url: turn.add_card_url, expiresAt: turn.add_card_expires_at }] };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The answer with only the link of the step the shopper takes now, when Agentcard names one
|
|
142
|
+
* (`next_action`): the Vault link waits for `add_card`, the approval link for `approve`. A store login
|
|
143
|
+
* link and product pages are not steps, so they stay. An answer that names no step keeps every link.
|
|
144
|
+
*/
|
|
145
|
+
function onlyNextStepLinks(result, next) {
|
|
146
|
+
if (!next)
|
|
147
|
+
return result;
|
|
148
|
+
const links = result.links.filter((link) => (link.kind === 'vault' ? next === 'add_card' : link.kind === 'approval' ? next === 'approve' : true));
|
|
149
|
+
return links.length === result.links.length ? result : { ...result, links };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The answer's `messages`, read once its `reply` is final: the messages Agentcard sent while the reply
|
|
153
|
+
* is still Agentcard's own, else the reply the tool wrote, each cut at its blank lines.
|
|
154
|
+
*/
|
|
155
|
+
function withMessages(result, turn) {
|
|
156
|
+
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
157
|
+
return { ...result, messages: sent.flatMap(toMessages) };
|
|
158
|
+
}
|
|
97
159
|
/** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
|
|
98
160
|
function resultOfTurn(turn) {
|
|
161
|
+
return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
|
|
162
|
+
}
|
|
163
|
+
/** A purchase answer, read for its status, with every link the answer carries. */
|
|
164
|
+
function resultOfStatus(turn) {
|
|
99
165
|
const answer = answerOf(turn);
|
|
166
|
+
const card = needsCardOf(turn, answer);
|
|
167
|
+
if (card)
|
|
168
|
+
return card;
|
|
100
169
|
switch (turn.status) {
|
|
101
170
|
case 'order_placed':
|
|
102
171
|
case 'partially_placed':
|
|
@@ -116,10 +185,6 @@ function resultOfTurn(turn) {
|
|
|
116
185
|
shopper_need_not_reply: turn.placement_announced === true,
|
|
117
186
|
};
|
|
118
187
|
}
|
|
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
188
|
return { ...answer, status: 'declined' };
|
|
124
189
|
default:
|
|
125
190
|
return answer;
|
|
@@ -132,6 +197,7 @@ function resultOfRefusal(error) {
|
|
|
132
197
|
const answer = (reply, carts) => ({
|
|
133
198
|
status: 'needs_input',
|
|
134
199
|
reply,
|
|
200
|
+
messages: [],
|
|
135
201
|
cart: carts.at(-1) ?? null,
|
|
136
202
|
carts,
|
|
137
203
|
products: [],
|
|
@@ -143,6 +209,8 @@ function resultOfRefusal(error) {
|
|
|
143
209
|
order_id: null,
|
|
144
210
|
decline_code: null,
|
|
145
211
|
error_code: error.code,
|
|
212
|
+
next_action: null,
|
|
213
|
+
pick: null,
|
|
146
214
|
hashes: hashesOf(null, carts),
|
|
147
215
|
envelope: error.body,
|
|
148
216
|
});
|
|
@@ -154,6 +222,8 @@ function resultOfRefusal(error) {
|
|
|
154
222
|
return answer(NOTHING_TO_CONFIRM, []);
|
|
155
223
|
if (error.code === 'turn_in_progress')
|
|
156
224
|
return answer(STILL_WORKING, []);
|
|
225
|
+
if (error.code === 'pick_not_found')
|
|
226
|
+
return answer(PICK_GONE, []);
|
|
157
227
|
return null;
|
|
158
228
|
}
|
|
159
229
|
/** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
|
|
@@ -161,6 +231,7 @@ function needsCard(link, body) {
|
|
|
161
231
|
return {
|
|
162
232
|
status: 'needs_card',
|
|
163
233
|
reply: CARD_NEEDED,
|
|
234
|
+
messages: [],
|
|
164
235
|
cart: null,
|
|
165
236
|
carts: [],
|
|
166
237
|
products: [],
|
|
@@ -172,6 +243,8 @@ function needsCard(link, body) {
|
|
|
172
243
|
order_id: null,
|
|
173
244
|
decline_code: null,
|
|
174
245
|
error_code: 'user_not_connected',
|
|
246
|
+
next_action: 'add_card',
|
|
247
|
+
pick: null,
|
|
175
248
|
hashes: [],
|
|
176
249
|
envelope: body,
|
|
177
250
|
};
|
|
@@ -249,6 +322,63 @@ export function buyTool(options) {
|
|
|
249
322
|
if (typeof wait !== 'function')
|
|
250
323
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
251
324
|
const who = client.company ? { externalUserId } : {};
|
|
325
|
+
if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
|
|
326
|
+
throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
|
|
327
|
+
if (o.ack !== undefined && o.ack !== false && typeof o.ack !== 'function')
|
|
328
|
+
throw new TypeError('Pass ack as false, or as a function that writes the instant reply from the ask.');
|
|
329
|
+
const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
|
|
330
|
+
if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
|
|
331
|
+
throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
|
|
332
|
+
const onWorking = o.ack === false ? undefined : o.onWorking;
|
|
333
|
+
const writeAck = typeof o.ack === 'function' ? o.ack : null;
|
|
334
|
+
/** Calls running now, when the last one ended, whether this turn's instant reply is taken, and the template it used last. */
|
|
335
|
+
let running = 0;
|
|
336
|
+
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
337
|
+
let turnAcked = false;
|
|
338
|
+
let lastTemplate = -1;
|
|
339
|
+
/** This turn's instant reply while it is being sent: every call of the turn waits for it before it hands over a link. */
|
|
340
|
+
let ackOut = null;
|
|
341
|
+
/**
|
|
342
|
+
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
343
|
+
* not gone out, and frees the turn's reply for a later call.
|
|
344
|
+
*/
|
|
345
|
+
const holdAck = (ask) => {
|
|
346
|
+
if (!onWorking || turnAcked || !isNewRequest(ask))
|
|
347
|
+
return null;
|
|
348
|
+
turnAcked = true;
|
|
349
|
+
let held = true;
|
|
350
|
+
const timer = setTimeout(() => {
|
|
351
|
+
held = false;
|
|
352
|
+
let text;
|
|
353
|
+
if (writeAck) {
|
|
354
|
+
try {
|
|
355
|
+
text = writeAck(ask);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
text = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
lastTemplate = nextTemplate(lastTemplate);
|
|
363
|
+
text = ackAt(lastTemplate, ask);
|
|
364
|
+
}
|
|
365
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
366
|
+
turnAcked = false;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const message = text.trim();
|
|
370
|
+
ackOut = Promise.resolve().then(() => onWorking(message)).catch(() => { });
|
|
371
|
+
}, ackDelayMs);
|
|
372
|
+
return {
|
|
373
|
+
cancel: () => {
|
|
374
|
+
if (!held)
|
|
375
|
+
return;
|
|
376
|
+
held = false;
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
turnAcked = false;
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
};
|
|
252
382
|
/** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
|
|
253
383
|
const read = async (conversationId) => {
|
|
254
384
|
try {
|
|
@@ -267,6 +397,7 @@ export function buyTool(options) {
|
|
|
267
397
|
status: 'order_placed',
|
|
268
398
|
reply: `The ${order.merchant || 'store'} order went through, order number ${order.orderId}.`,
|
|
269
399
|
links: [],
|
|
400
|
+
next_action: null,
|
|
270
401
|
order_id: order.orderId,
|
|
271
402
|
conversation_id: conversationId,
|
|
272
403
|
};
|
|
@@ -280,6 +411,7 @@ export function buyTool(options) {
|
|
|
280
411
|
links: [],
|
|
281
412
|
order_id: order.orderId,
|
|
282
413
|
decline_code: `order_${order.status}`,
|
|
414
|
+
next_action: null,
|
|
283
415
|
shopper_need_not_reply: false,
|
|
284
416
|
conversation_id: conversationId,
|
|
285
417
|
};
|
|
@@ -291,6 +423,7 @@ export function buyTool(options) {
|
|
|
291
423
|
reply: refusal.message || `${NOT_PLACED} The ${refusal.merchant || 'store'} answered ${refusal.code}.`,
|
|
292
424
|
links: [],
|
|
293
425
|
decline_code: refusal.code,
|
|
426
|
+
next_action: null,
|
|
294
427
|
shopper_need_not_reply: false,
|
|
295
428
|
});
|
|
296
429
|
/**
|
|
@@ -306,7 +439,7 @@ export function buyTool(options) {
|
|
|
306
439
|
const refusal = conversation && checkoutRefusal(conversation);
|
|
307
440
|
if (refusal)
|
|
308
441
|
return refusedCheckout(base, refusal);
|
|
309
|
-
return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
|
|
442
|
+
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
|
|
310
443
|
};
|
|
311
444
|
/**
|
|
312
445
|
* What a finished turn's checkout left besides an order, which `placedOrderOf`
|
|
@@ -324,6 +457,7 @@ export function buyTool(options) {
|
|
|
324
457
|
reply: last.message || CARD_NEEDED,
|
|
325
458
|
links: [{ kind: 'vault', url: last.add_card_url, expiresAt: last.add_card_expires_at }],
|
|
326
459
|
decline_code: last.decline_code,
|
|
460
|
+
next_action: 'add_card',
|
|
327
461
|
shopper_need_not_reply: false,
|
|
328
462
|
envelope: conversation.raw,
|
|
329
463
|
};
|
|
@@ -335,13 +469,14 @@ export function buyTool(options) {
|
|
|
335
469
|
}
|
|
336
470
|
const announced = last.placement_announced === true;
|
|
337
471
|
if (last.approval_in_progress === true)
|
|
338
|
-
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], shopper_need_not_reply: announced };
|
|
472
|
+
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null, shopper_need_not_reply: announced };
|
|
339
473
|
return {
|
|
340
474
|
...base,
|
|
341
475
|
status: 'approval_sent',
|
|
342
476
|
reply: APPROVAL_WAITING,
|
|
343
477
|
links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
|
|
344
478
|
approval_expires_at: last.approval_expires_at,
|
|
479
|
+
next_action: 'approve',
|
|
345
480
|
shopper_need_not_reply: announced,
|
|
346
481
|
envelope: conversation.raw,
|
|
347
482
|
};
|
|
@@ -399,6 +534,10 @@ export function buyTool(options) {
|
|
|
399
534
|
carts: conversation.carts,
|
|
400
535
|
hashes: conversation.carts.map((c) => c.hash),
|
|
401
536
|
error_code: newest ? null : base.error_code,
|
|
537
|
+
// A read replaced the answer the step was named for, and names no step of its own: the reply asks.
|
|
538
|
+
next_action: null,
|
|
539
|
+
// The early answer judged the pick before the turn finished adding it.
|
|
540
|
+
pick: pickAfter(base.pick, conversation.carts),
|
|
402
541
|
envelope: conversation.raw,
|
|
403
542
|
};
|
|
404
543
|
if (!conversation.turn_in_progress)
|
|
@@ -408,7 +547,7 @@ export function buyTool(options) {
|
|
|
408
547
|
}
|
|
409
548
|
}
|
|
410
549
|
if (placing)
|
|
411
|
-
return { ...base, status: 'placing', reply: STILL_PLACING, links: [] };
|
|
550
|
+
return { ...base, status: 'placing', reply: STILL_PLACING, links: [], next_action: null };
|
|
412
551
|
// A minute that ends with the turn still running returns what the last read showed, a new cart as
|
|
413
552
|
// ready although no read handed it back: nothing better exists by then, and a confirm that misses it
|
|
414
553
|
// is refused, never placed on another cart.
|
|
@@ -479,7 +618,7 @@ export function buyTool(options) {
|
|
|
479
618
|
for (const [url, product] of pages) {
|
|
480
619
|
if (!before.has(url)) {
|
|
481
620
|
try {
|
|
482
|
-
await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, expiresAt: null });
|
|
621
|
+
await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), productId: product.id, imageUrl: product.imageUrl, expiresAt: null });
|
|
483
622
|
}
|
|
484
623
|
catch {
|
|
485
624
|
continue;
|
|
@@ -515,22 +654,43 @@ export function buyTool(options) {
|
|
|
515
654
|
const ask = input.ask.trim();
|
|
516
655
|
if (!confirm && ask === '')
|
|
517
656
|
throw new TypeError("Put the shopper's words in ask.");
|
|
657
|
+
// A pick chooses from the products this conversation showed: never on a confirm, and never on a new order, which shows none yet.
|
|
658
|
+
const pick = !confirm && !input.new_order && input.product_id ? { product_id: input.product_id } : null;
|
|
518
659
|
const body = {
|
|
519
660
|
resume: true,
|
|
520
661
|
...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
|
|
521
662
|
...(input.new_order ? { new_order: true } : {}),
|
|
663
|
+
...(pick ? { pick } : {}),
|
|
522
664
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
523
665
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
524
666
|
};
|
|
525
|
-
|
|
526
|
-
|
|
667
|
+
// A call that starts while another runs, or right after one ended, is the model working through the same turn.
|
|
668
|
+
if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
|
|
669
|
+
turnAcked = false;
|
|
670
|
+
ackOut = null;
|
|
671
|
+
}
|
|
672
|
+
running += 1;
|
|
673
|
+
// A pick adds a product the shopper already saw, so no "looking for" reply goes out, as for "the first one".
|
|
674
|
+
const ack = confirm || pick ? null : holdAck(ask);
|
|
527
675
|
try {
|
|
528
|
-
turn =
|
|
676
|
+
let turn = null;
|
|
677
|
+
let refusal;
|
|
678
|
+
try {
|
|
679
|
+
turn = await client.buy(body);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
refusal = error;
|
|
683
|
+
}
|
|
684
|
+
ack?.cancel();
|
|
685
|
+
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
|
|
686
|
+
await ackOut;
|
|
687
|
+
return await deliver(withMessages(settled, turn));
|
|
529
688
|
}
|
|
530
|
-
|
|
531
|
-
|
|
689
|
+
finally {
|
|
690
|
+
ack?.cancel();
|
|
691
|
+
running -= 1;
|
|
692
|
+
lastEndedAt = Date.now();
|
|
532
693
|
}
|
|
533
|
-
return deliver(turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt));
|
|
534
694
|
};
|
|
535
695
|
/** Checks the model's input, with every link taken out of a refusal. */
|
|
536
696
|
const parse = (input) => {
|
package/dist/client.d.ts
CHANGED
|
@@ -51,6 +51,13 @@ export interface BuyRequest {
|
|
|
51
51
|
resume?: true;
|
|
52
52
|
/** Drop the cart on the table and start a new order. */
|
|
53
53
|
new_order?: true;
|
|
54
|
+
/**
|
|
55
|
+
* The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
|
|
56
|
+
* Agentcard adds exactly that item, with no new search. Goes with an `ask`, never with `confirm` or `new_order`.
|
|
57
|
+
*/
|
|
58
|
+
pick?: {
|
|
59
|
+
product_id: string;
|
|
60
|
+
};
|
|
54
61
|
payment_source?: 'vault';
|
|
55
62
|
delivery_address?: BuyDeliveryAddress;
|
|
56
63
|
external_user_id?: string;
|
|
@@ -87,8 +94,22 @@ export interface BuyCart {
|
|
|
87
94
|
hash: string;
|
|
88
95
|
}
|
|
89
96
|
/** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
|
|
97
|
+
/** Supplier facts only. Missing facts stay null; fare cabin does not establish segment cabins. */
|
|
98
|
+
export interface BuyFlightItinerary {
|
|
99
|
+
fareCabinClass?: string | null;
|
|
100
|
+
legs: Array<{
|
|
101
|
+
origin: string | null;
|
|
102
|
+
destination: string | null;
|
|
103
|
+
departureAt: string | null;
|
|
104
|
+
arrivalAt: string | null;
|
|
105
|
+
cabinClasses: Array<string | null>;
|
|
106
|
+
}>;
|
|
107
|
+
}
|
|
90
108
|
export interface BuyCatalogItem {
|
|
109
|
+
flight?: BuyFlightItinerary;
|
|
91
110
|
id: string;
|
|
111
|
+
/** The item's id with no link in it, which `pick.product_id` takes as it takes `id`; null from an older API. */
|
|
112
|
+
ref: string | null;
|
|
92
113
|
name: string | null;
|
|
93
114
|
priceCents: number | null;
|
|
94
115
|
/** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
|
|
@@ -104,11 +125,25 @@ export interface BuyCatalog {
|
|
|
104
125
|
merchant_name: string;
|
|
105
126
|
items: BuyCatalogItem[];
|
|
106
127
|
}
|
|
128
|
+
/** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
|
|
129
|
+
export interface BuyPick {
|
|
130
|
+
product_id: string;
|
|
131
|
+
ref: string | null;
|
|
132
|
+
in_cart: boolean;
|
|
133
|
+
}
|
|
107
134
|
export interface BuyPaymentSource {
|
|
108
135
|
source: string;
|
|
109
136
|
brand: string | null;
|
|
110
137
|
last4: string | null;
|
|
111
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The one thing the shopper does next, as Agentcard names it, in the order a purchase asks for them:
|
|
141
|
+
* `shipping_details` (a shipping address and phone), `guest_details` (a name, email and phone for a
|
|
142
|
+
* booking), `add_card` (a card in their Vault), `confirm` (their yes to the cart), `verification_code`
|
|
143
|
+
* (the code a booking texted them), then `approve` (the approval link). A value this version does
|
|
144
|
+
* not know reads as null.
|
|
145
|
+
*/
|
|
146
|
+
export type BuyNextAction = 'shipping_details' | 'guest_details' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
|
|
112
147
|
/** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
|
|
113
148
|
export interface BuyTurn {
|
|
114
149
|
conversation_id: string;
|
|
@@ -133,11 +168,15 @@ export interface BuyTurn {
|
|
|
133
168
|
placement_announced: boolean | null;
|
|
134
169
|
approval_expires_at: string | null;
|
|
135
170
|
approval_in_progress: boolean | null;
|
|
171
|
+
/** The one step the shopper takes next, or null when nothing waits on them or Agentcard named none. */
|
|
172
|
+
next_action: BuyNextAction | null;
|
|
136
173
|
/** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
|
|
137
174
|
store_login_url: string | null;
|
|
138
175
|
store_login_expires_at: string | null;
|
|
139
176
|
/** The store that page signs in to. */
|
|
140
177
|
store_login_merchant_name: string | null;
|
|
178
|
+
/** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
|
|
179
|
+
pick: BuyPick | null;
|
|
141
180
|
/** The answer as Agentcard sent it. */
|
|
142
181
|
raw: Record<string, unknown>;
|
|
143
182
|
}
|
|
@@ -189,7 +228,7 @@ export interface VaultLink {
|
|
|
189
228
|
/**
|
|
190
229
|
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
191
230
|
* (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
|
|
192
|
-
* `user_id_mismatch`), one read from the shape of the answer
|
|
231
|
+
* `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
|
|
193
232
|
* (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
|
|
194
233
|
* `conversation_not_found`), or the client's (`unreachable`, `timeout`,
|
|
195
234
|
* `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
|
package/dist/client.js
CHANGED
|
@@ -16,10 +16,11 @@ 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`,
|
|
22
|
-
* `user_id_mismatch`), one read from the shape of the answer
|
|
23
|
+
* `user_id_mismatch`, `pick_not_found`), one read from the shape of the answer
|
|
23
24
|
* (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
|
|
24
25
|
* `conversation_not_found`), or the client's (`unreachable`, `timeout`,
|
|
25
26
|
* `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
|
|
@@ -97,18 +98,36 @@ 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
|
+
ref: str(i.ref),
|
|
122
|
+
name: str(i.name),
|
|
123
|
+
priceCents: num(i.priceCents),
|
|
124
|
+
currency: str(i.currency),
|
|
125
|
+
image_url: str(i.image_url),
|
|
126
|
+
...(flight ? { flight } : {}),
|
|
127
|
+
store: storeOf(i.store),
|
|
128
|
+
url: pageUrl(i.url),
|
|
129
|
+
};
|
|
130
|
+
}) : [];
|
|
112
131
|
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
|
|
113
132
|
}
|
|
114
133
|
function paymentSourceOf(v) {
|
|
@@ -142,6 +161,8 @@ export function turnOf(body) {
|
|
|
142
161
|
store_login_url: str(body.store_login_url),
|
|
143
162
|
store_login_expires_at: str(body.store_login_expires_at),
|
|
144
163
|
store_login_merchant_name: str(body.store_login_merchant_name),
|
|
164
|
+
next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
|
|
165
|
+
pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
|
|
145
166
|
raw: body,
|
|
146
167
|
};
|
|
147
168
|
}
|
package/dist/definition.d.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
-
* JSON Schema with
|
|
3
|
+
* JSON Schema with four required fields, `product_id` null unless the shopper
|
|
4
|
+
* picks a product they were shown. The delivery address and the
|
|
4
5
|
* shopper's id are construction options, never model inputs, so a model can
|
|
5
6
|
* neither change where an order goes nor buy for someone else.
|
|
6
7
|
*/
|
|
7
8
|
export declare const BUY_TOOL_NAME = "buy";
|
|
8
9
|
/** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
|
|
9
10
|
export declare const BUY_TOOL_DESCRIPTION: string;
|
|
10
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* What the model sends: `ask`, `confirm` and `new_order` every time, and `product_id`, null unless the
|
|
13
|
+
* shopper picks a product from the last result. Your own code may leave `product_id` out.
|
|
14
|
+
*/
|
|
11
15
|
export interface BuyToolInput {
|
|
12
16
|
ask: string;
|
|
13
17
|
confirm: boolean;
|
|
14
18
|
new_order: boolean;
|
|
19
|
+
/** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
|
|
20
|
+
product_id?: string | null;
|
|
15
21
|
}
|
|
16
22
|
/** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
|
|
17
23
|
export type BuyToolInputSchema = {
|
|
@@ -29,6 +35,10 @@ export type BuyToolInputSchema = {
|
|
|
29
35
|
type: 'boolean';
|
|
30
36
|
description: string;
|
|
31
37
|
};
|
|
38
|
+
product_id: {
|
|
39
|
+
type: ['string', 'null'];
|
|
40
|
+
description: string;
|
|
41
|
+
};
|
|
32
42
|
};
|
|
33
43
|
required: Array<keyof BuyToolInput>;
|
|
34
44
|
additionalProperties: false;
|
|
@@ -42,8 +52,9 @@ export interface BuyToolDefinition {
|
|
|
42
52
|
export declare function buyToolDefinition(): BuyToolDefinition;
|
|
43
53
|
/**
|
|
44
54
|
* The model's input, checked against the schema: an object, or its JSON text,
|
|
45
|
-
* with `ask` a string
|
|
46
|
-
* else. Throws a TypeError whose message
|
|
55
|
+
* with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
|
|
56
|
+
* string, null or left out, and nothing else. Throws a TypeError whose message
|
|
57
|
+
* tells the model what to send.
|
|
47
58
|
*/
|
|
48
59
|
export declare function parseBuyToolInput(value: unknown): BuyToolInput;
|
|
49
60
|
/** The buy tool in OpenAI's function-calling format, which the OpenAI SDK, OpenRouter's chat API and most other model APIs take. */
|
package/dist/definition.js
CHANGED
|
@@ -1,6 +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
|
|
3
|
+
* JSON Schema with four required fields, `product_id` null unless the shopper
|
|
4
|
+
* picks a product they were shown. The delivery address and the
|
|
4
5
|
* shopper's id are construction options, never model inputs, so a model can
|
|
5
6
|
* neither change where an order goes nor buy for someone else.
|
|
6
7
|
*/
|
|
@@ -11,6 +12,7 @@ export const BUY_TOOL_DESCRIPTION = [
|
|
|
11
12
|
"Put the shopper's own words in `ask`.",
|
|
12
13
|
'Tell the shopper what `reply` says, in your own voice.',
|
|
13
14
|
'Show the shopper the cart before you confirm it.',
|
|
15
|
+
'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
16
|
'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
17
|
'Never write a link.',
|
|
16
18
|
'Each link reaches the shopper on its own.',
|
|
@@ -21,6 +23,8 @@ export const BUY_TOOL_DESCRIPTION = [
|
|
|
21
23
|
'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
|
|
22
24
|
"After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
|
|
23
25
|
'Set `new_order` to true only when the shopper drops the cart.',
|
|
26
|
+
"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.",
|
|
27
|
+
'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.',
|
|
24
28
|
].join(' ');
|
|
25
29
|
/** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
|
|
26
30
|
export function buyToolDefinition() {
|
|
@@ -36,17 +40,22 @@ export function buyToolDefinition() {
|
|
|
36
40
|
description: '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. False otherwise.',
|
|
37
41
|
},
|
|
38
42
|
new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
|
|
43
|
+
product_id: {
|
|
44
|
+
type: ['string', 'null'],
|
|
45
|
+
description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
|
|
46
|
+
},
|
|
39
47
|
},
|
|
40
|
-
required: ['ask', 'confirm', 'new_order'],
|
|
48
|
+
required: ['ask', 'confirm', 'new_order', 'product_id'],
|
|
41
49
|
additionalProperties: false,
|
|
42
50
|
},
|
|
43
51
|
};
|
|
44
52
|
}
|
|
45
|
-
const FIELDS = new Set(['ask', 'confirm', 'new_order']);
|
|
53
|
+
const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
|
|
46
54
|
/**
|
|
47
55
|
* The model's input, checked against the schema: an object, or its JSON text,
|
|
48
|
-
* with `ask` a string
|
|
49
|
-
* else. Throws a TypeError whose message
|
|
56
|
+
* with `ask` a string, `confirm` and `new_order` booleans, `product_id` a
|
|
57
|
+
* string, null or left out, and nothing else. Throws a TypeError whose message
|
|
58
|
+
* tells the model what to send.
|
|
50
59
|
*/
|
|
51
60
|
export function parseBuyToolInput(value) {
|
|
52
61
|
let input = value;
|
|
@@ -64,14 +73,18 @@ export function parseBuyToolInput(value) {
|
|
|
64
73
|
const fields = input;
|
|
65
74
|
const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
|
|
66
75
|
if (extra !== undefined)
|
|
67
|
-
throw new TypeError(`The buy tool takes only ask, confirm and
|
|
76
|
+
throw new TypeError(`The buy tool takes only ask, confirm, new_order and product_id, and ${extra} is not one of them.`);
|
|
68
77
|
if (typeof fields.ask !== 'string')
|
|
69
78
|
throw new TypeError("Set ask to the shopper's words, as a string.");
|
|
70
79
|
if (typeof fields.confirm !== 'boolean')
|
|
71
80
|
throw new TypeError('Set confirm to true or false.');
|
|
72
81
|
if (typeof fields.new_order !== 'boolean')
|
|
73
82
|
throw new TypeError('Set new_order to true or false.');
|
|
74
|
-
|
|
83
|
+
const productId = fields.product_id;
|
|
84
|
+
if (productId !== undefined && productId !== null && typeof productId !== 'string') {
|
|
85
|
+
throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
|
|
86
|
+
}
|
|
87
|
+
return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null };
|
|
75
88
|
}
|
|
76
89
|
/** 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. */
|
|
77
90
|
export function openAIBuyTool() {
|