@agent-cards/commerce-sdk 0.5.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 +96 -11
- package/dist/ack.d.ts +36 -8
- package/dist/ack.js +179 -16
- package/dist/adapters.js +1 -0
- package/dist/buy-tool.d.ts +79 -5
- package/dist/buy-tool.js +206 -29
- package/dist/client.d.ts +93 -7
- package/dist/client.js +158 -12
- package/dist/definition.d.ts +14 -6
- package/dist/definition.js +24 -8
- package/dist/events.d.ts +12 -0
- package/dist/events.js +79 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/listen.d.ts +93 -0
- package/dist/listen.js +496 -0
- package/dist/model-view.d.ts +38 -6
- package/dist/model-view.js +51 -9
- package/package.json +2 -2
package/dist/buy-tool.d.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* A turn that outlived Agentcard's budget gets a read every 5 seconds for up
|
|
23
23
|
* to a minute, until the turn ends.
|
|
24
24
|
*/
|
|
25
|
-
import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type FetchLike } from './client.js';
|
|
25
|
+
import { AgentcardClient, type BuyCart, type BuyFlightItinerary, type BuyDeliveryAddress, type BuyNextAction, type BuyPick, type BuyProgress, type 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
|
/**
|
|
@@ -40,10 +40,12 @@ export type BuyToolStatus = 'needs_input' | 'needs_card' | 'approval_sent' | 'pl
|
|
|
40
40
|
* card. `approval`: the page where they approve this purchase. `store_login`:
|
|
41
41
|
* the page where they sign in to the store named in `store`, before Agentcard
|
|
42
42
|
* can buy there. `product`: a product's page at its store, with its `title`,
|
|
43
|
-
* `price`, `productId` and `imageUrl`.
|
|
43
|
+
* `price`, `productId` and `imageUrl`. The page is `url`; `productId` holds no link.
|
|
44
|
+
* `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;
|
|
@@ -53,10 +55,34 @@ export interface BuyToolLink {
|
|
|
53
55
|
title?: string;
|
|
54
56
|
/** On a product link: the price as the shopper reads it, such as `$12.99`, or null when Agentcard named none. */
|
|
55
57
|
price?: string | null;
|
|
56
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* On a product link: Agentcard's id for the product, with no link in it. It is the `product_id` the
|
|
60
|
+
* model reads for the same product, and the tool takes it as `product_id`, so a "Buy this" button
|
|
61
|
+
* can hand it back as the shopper's pick. Absent when Agentcard sent no such id, as an older API does.
|
|
62
|
+
*/
|
|
57
63
|
productId?: string;
|
|
58
64
|
/** On a product link: the product's picture, or null when Agentcard sent none. Show it beside the title. */
|
|
59
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;
|
|
60
86
|
}
|
|
61
87
|
/** A product the last search found. */
|
|
62
88
|
export interface BuyToolProduct {
|
|
@@ -104,6 +130,11 @@ export interface BuyToolResult {
|
|
|
104
130
|
approval_expires_at: string | null;
|
|
105
131
|
/** True when Agentcard tells the shopper how the order went, so the shopper need not reply. */
|
|
106
132
|
shopper_need_not_reply: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* True when `onWorking` delivered this turn's instant reply, such as `got it, finding a taco`, so the
|
|
135
|
+
* shopper already heard that the request is being worked on. Absent or false when none went out.
|
|
136
|
+
*/
|
|
137
|
+
shopper_already_told_looking?: boolean;
|
|
107
138
|
conversation_id: string | null;
|
|
108
139
|
order_id: string | null;
|
|
109
140
|
decline_code: string | null;
|
|
@@ -112,6 +143,10 @@ export interface BuyToolResult {
|
|
|
112
143
|
next_action: BuyNextAction | null;
|
|
113
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. */
|
|
114
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;
|
|
115
150
|
/** Every cart hash in this answer. */
|
|
116
151
|
hashes: string[];
|
|
117
152
|
/** Agentcard's answer as it came: the purchase answer, or the body of the refusal. */
|
|
@@ -148,10 +183,36 @@ export interface BuyToolOptions {
|
|
|
148
183
|
* it before any link or the model's answer. One that throws costs nothing but the instant reply.
|
|
149
184
|
*/
|
|
150
185
|
onWorking?: (message: string) => void | Promise<void>;
|
|
151
|
-
/** `false` sends no instant reply. A function writes it from the shopper's words instead, and an empty answer sends none. */
|
|
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. */
|
|
152
187
|
ack?: false | ((ask: string) => string | null | undefined);
|
|
153
188
|
/** How long Agentcard has to answer before the instant reply goes out. Default 1500. */
|
|
154
189
|
ackDelayMs?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Called with the whole answer once per call, after its links went out and before the model reads
|
|
192
|
+
* it, so your app can show the carts, the status, `next_action` or the order id without wrapping the
|
|
193
|
+
* tool. A refusal the tool turns into an answer, such as a Vault link for a shopper with no card,
|
|
194
|
+
* counts; a call that throws calls nothing. One that throws costs nothing but the call to it.
|
|
195
|
+
*/
|
|
196
|
+
onResult?: (result: BuyToolResult) => void | Promise<void>;
|
|
197
|
+
/**
|
|
198
|
+
* Called while Agentcard works on a call, with the stage it reached: `searching`, `building_cart`,
|
|
199
|
+
* `checking_out` or `working`. Show it as a status line. One that throws costs nothing but the call to it.
|
|
200
|
+
*/
|
|
201
|
+
onProgress?: (progress: BuyProgress) => void;
|
|
202
|
+
/**
|
|
203
|
+
* `false` waits for each whole answer. With `onProgress`, or with `earlyCards` and `sendLink`, the
|
|
204
|
+
* tool asks Agentcard to stream the call, and each stage goes to `onProgress` as it happens.
|
|
205
|
+
*/
|
|
206
|
+
stream?: false;
|
|
207
|
+
/**
|
|
208
|
+
* `true` sends the store pages of a search's first three products to `sendLink` the moment the search
|
|
209
|
+
* answers, before Agentcard writes its reply, and the answer then sends only the pages it adds. Off by
|
|
210
|
+
* default: the reply often recommends other products than the search's first three, so a shopper
|
|
211
|
+
* would hold cards the reply never names. Measured on five live searches on 2026-10-20, the early
|
|
212
|
+
* pages arrived 2.2 to 3.3 seconds before the answer, and in four of them the reply named a product
|
|
213
|
+
* outside the first three.
|
|
214
|
+
*/
|
|
215
|
+
earlyCards?: boolean;
|
|
155
216
|
}
|
|
156
217
|
/**
|
|
157
218
|
* The buy tool, in the shape every agent framework takes, because their field
|
|
@@ -193,7 +254,20 @@ export interface BuyTool {
|
|
|
193
254
|
answer(input: BuyToolInput): Promise<BuyToolResult>;
|
|
194
255
|
/** The answer as the model may read it, with every link taken out. */
|
|
195
256
|
forModel(result: BuyToolResult): BuyToolModelView;
|
|
257
|
+
/**
|
|
258
|
+
* The instant reply for a shopper's message, such as `on it, looking for usb-c cables under $20`, or
|
|
259
|
+
* null for a message that starts no search: `yes`, `1`, `ok`, a code, `hi`, or the shopper's own
|
|
260
|
+
* details, such as a name, a phone number or an address, and any message that answers a request for
|
|
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.
|
|
266
|
+
*/
|
|
267
|
+
instantAck(shopperText: string): string | null;
|
|
196
268
|
}
|
|
269
|
+
/** Shared with the merchant monitor so link eligibility uses the tool's product mapping. */
|
|
270
|
+
export declare function productsOf(turn: BuyTurn): BuyToolProduct[];
|
|
197
271
|
/**
|
|
198
272
|
* A buy tool for one shopper. With a company client, name the shopper with
|
|
199
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, 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, 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,14 @@ 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
|
+
}
|
|
55
|
+
/** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
|
|
56
|
+
const APP_TURN_MS = 120_000;
|
|
49
57
|
const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
|
|
50
58
|
const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
|
|
51
59
|
const STILL_PLACING = 'The shopper approved, and the order is still going through.';
|
|
@@ -57,6 +65,7 @@ const ANSWER_LATE = "Agentcard's answer took too long to come back. Call this to
|
|
|
57
65
|
const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shopper the cart as it stands now.';
|
|
58
66
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
59
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.";
|
|
60
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.";
|
|
61
70
|
/**
|
|
62
71
|
* The pick as a later read of the conversation shows it: a pick the early answer found missing is in
|
|
@@ -72,8 +81,13 @@ function pickAfter(pick, carts) {
|
|
|
72
81
|
function hashesOf(cart, carts) {
|
|
73
82
|
return [...new Set([cart?.hash, ...carts.map((c) => c.hash)].filter((h) => typeof h === 'string' && h !== ''))];
|
|
74
83
|
}
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
/** Shared with the merchant monitor so link eligibility uses the tool's product mapping. */
|
|
85
|
+
export function productsOf(turn) {
|
|
86
|
+
return turn.catalog ? productsOfCatalog(turn.catalog) : [];
|
|
87
|
+
}
|
|
88
|
+
/** The products of a search, as the tool reads them. */
|
|
89
|
+
function productsOfCatalog(catalog) {
|
|
90
|
+
return (catalog.items ?? [])
|
|
77
91
|
.filter((item) => item.name)
|
|
78
92
|
.map((item) => ({
|
|
79
93
|
id: item.id,
|
|
@@ -83,11 +97,15 @@ function productsOf(turn) {
|
|
|
83
97
|
currency: item.currency,
|
|
84
98
|
imageUrl: item.image_url,
|
|
85
99
|
...(item.flight ? { flight: item.flight } : {}),
|
|
86
|
-
store: item.store?.name ?? item.store?.id ??
|
|
100
|
+
store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
|
|
87
101
|
url: item.url ?? null,
|
|
88
102
|
linkSent: false,
|
|
89
103
|
}));
|
|
90
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
|
+
}
|
|
91
109
|
/** The store login link an answer carries, read from its own fields: a link in `reply` is never pulled out. */
|
|
92
110
|
function storeLoginOf(turn) {
|
|
93
111
|
if (!turn.store_login_url)
|
|
@@ -113,6 +131,8 @@ function answerOf(turn) {
|
|
|
113
131
|
error_code: turn.error_code,
|
|
114
132
|
next_action: turn.next_action,
|
|
115
133
|
pick: turn.pick,
|
|
134
|
+
stores: storesOf(turn.stores),
|
|
135
|
+
store_pick: turn.store_pick,
|
|
116
136
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
117
137
|
envelope: turn.raw,
|
|
118
138
|
};
|
|
@@ -211,6 +231,8 @@ function resultOfRefusal(error) {
|
|
|
211
231
|
error_code: error.code,
|
|
212
232
|
next_action: null,
|
|
213
233
|
pick: null,
|
|
234
|
+
stores: [],
|
|
235
|
+
store_pick: null,
|
|
214
236
|
hashes: hashesOf(null, carts),
|
|
215
237
|
envelope: error.body,
|
|
216
238
|
});
|
|
@@ -224,6 +246,8 @@ function resultOfRefusal(error) {
|
|
|
224
246
|
return answer(STILL_WORKING, []);
|
|
225
247
|
if (error.code === 'pick_not_found')
|
|
226
248
|
return answer(PICK_GONE, []);
|
|
249
|
+
if (error.code === 'store_pick_not_found')
|
|
250
|
+
return answer(STORE_PICK_GONE, []);
|
|
227
251
|
return null;
|
|
228
252
|
}
|
|
229
253
|
/** A shopper Agentcard has not connected to this company yet: the Vault link where they connect and add a card. */
|
|
@@ -245,6 +269,8 @@ function needsCard(link, body) {
|
|
|
245
269
|
error_code: 'user_not_connected',
|
|
246
270
|
next_action: 'add_card',
|
|
247
271
|
pick: null,
|
|
272
|
+
stores: [],
|
|
273
|
+
store_pick: null,
|
|
248
274
|
hashes: [],
|
|
249
275
|
envelope: body,
|
|
250
276
|
};
|
|
@@ -318,6 +344,19 @@ export function buyTool(options) {
|
|
|
318
344
|
throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
|
|
319
345
|
}
|
|
320
346
|
const sendLink = o.sendLink;
|
|
347
|
+
if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
|
|
348
|
+
throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
|
|
349
|
+
if (o.stream !== undefined && o.stream !== false)
|
|
350
|
+
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
351
|
+
const onProgress = o.onProgress;
|
|
352
|
+
if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
|
|
353
|
+
throw new TypeError('Pass earlyCards as true or false.');
|
|
354
|
+
const earlyCards = o.earlyCards === true && !!o.sendLink;
|
|
355
|
+
const streams = o.stream !== false && (earlyCards || !!onProgress);
|
|
356
|
+
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
357
|
+
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
358
|
+
}
|
|
359
|
+
const onResult = o.onResult;
|
|
321
360
|
const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
322
361
|
if (typeof wait !== 'function')
|
|
323
362
|
throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
|
|
@@ -336,21 +375,55 @@ export function buyTool(options) {
|
|
|
336
375
|
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
337
376
|
let turnAcked = false;
|
|
338
377
|
let lastTemplate = -1;
|
|
378
|
+
/** Counts the model's turns: the calls of one turn share its number. */
|
|
379
|
+
let modelTurn = 0;
|
|
339
380
|
/** 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
381
|
let ackOut = null;
|
|
382
|
+
/** True once `onWorking` delivered this turn's instant reply, so the model is told not to acknowledge again. */
|
|
383
|
+
let ackDelivered = false;
|
|
384
|
+
/**
|
|
385
|
+
* The app's instant reply for the shopper's newest message, waiting for the model's first call on that
|
|
386
|
+
* message, and the step the last answer asked the shopper for. A call still running for an earlier
|
|
387
|
+
* message keeps its own turn's state.
|
|
388
|
+
*/
|
|
389
|
+
let appAck = null;
|
|
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
|
+
};
|
|
401
|
+
/** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
|
|
402
|
+
const instantAck = (shopperText) => {
|
|
403
|
+
const text = String(shopperText ?? '');
|
|
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;
|
|
407
|
+
// The same rotation onWorking uses, so the next reply comes from another template.
|
|
408
|
+
if (said)
|
|
409
|
+
lastTemplate = nextTemplate(lastTemplate);
|
|
410
|
+
appAck = { at: Date.now(), said, pick };
|
|
411
|
+
return said ? ackAt(lastTemplate, text) : null;
|
|
412
|
+
};
|
|
341
413
|
/**
|
|
342
414
|
* Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
|
|
343
415
|
* not gone out, and frees the turn's reply for a later call.
|
|
344
416
|
*/
|
|
345
|
-
const holdAck = (ask) => {
|
|
346
|
-
if (!onWorking || turnAcked || !isNewRequest(ask))
|
|
417
|
+
const holdAck = (ask, neutral) => {
|
|
418
|
+
if (!onWorking || turnAcked || (!neutral && (!isNewRequest(ask) || namesShownProduct(ask))))
|
|
347
419
|
return null;
|
|
348
420
|
turnAcked = true;
|
|
349
421
|
let held = true;
|
|
350
422
|
const timer = setTimeout(() => {
|
|
351
423
|
held = false;
|
|
352
424
|
let text;
|
|
353
|
-
|
|
425
|
+
// A neutral turn's message held no request safe to read back, so no writer sees it.
|
|
426
|
+
if (writeAck && !neutral) {
|
|
354
427
|
try {
|
|
355
428
|
text = writeAck(ask);
|
|
356
429
|
}
|
|
@@ -360,14 +433,17 @@ export function buyTool(options) {
|
|
|
360
433
|
}
|
|
361
434
|
else {
|
|
362
435
|
lastTemplate = nextTemplate(lastTemplate);
|
|
363
|
-
|
|
436
|
+
// A neutral reply names nothing: the shopper's message held no request safe to read back.
|
|
437
|
+
text = ackAt(lastTemplate, neutral ? '' : ask);
|
|
364
438
|
}
|
|
365
439
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
366
440
|
turnAcked = false;
|
|
367
441
|
return;
|
|
368
442
|
}
|
|
369
443
|
const message = text.trim();
|
|
370
|
-
ackOut = Promise.resolve()
|
|
444
|
+
ackOut = Promise.resolve()
|
|
445
|
+
.then(() => onWorking(message))
|
|
446
|
+
.then(() => { ackDelivered = true; }, () => { });
|
|
371
447
|
}, ackDelayMs);
|
|
372
448
|
return {
|
|
373
449
|
cancel: () => {
|
|
@@ -594,15 +670,21 @@ export function buyTool(options) {
|
|
|
594
670
|
// A running turn that placed nothing may be a search rather than a placement, so the shopper hears it is still working.
|
|
595
671
|
return settled.status === 'placing' && !placing ? refused : settled;
|
|
596
672
|
};
|
|
597
|
-
/** The store pages this tool sent last, with their conversation. */
|
|
673
|
+
/** The store pages this tool sent last, with their conversation, the model turn that sent them, and the search they list. */
|
|
598
674
|
let lastPages = null;
|
|
599
675
|
/**
|
|
600
676
|
* The store page of each product the model reads, sent once per page. A
|
|
601
677
|
* page the last batch in this conversation already sent stays with the
|
|
602
|
-
* shopper and goes out no second time
|
|
678
|
+
* shopper and goes out no second time, unless a later model turn's reply
|
|
679
|
+
* presents the products again (the catalog's `presented`): a repeated ask
|
|
680
|
+
* answered with the same products gets their pages beside it, so every
|
|
681
|
+
* product the reply names has a card. Calls of one model turn still send a
|
|
682
|
+
* page once. An answer that lists fewer of the same search's products, such
|
|
683
|
+
* as a pick's, keeps the pages that search already sent, so a later answer
|
|
684
|
+
* that lists them again sends none twice. A page whose `sendLink` throws
|
|
603
685
|
* stays unsent, and the call goes on, so the next answer tries it again.
|
|
604
686
|
*/
|
|
605
|
-
const deliverPages = async (result) => {
|
|
687
|
+
const deliverPages = async (result, call) => {
|
|
606
688
|
if (!sendLink)
|
|
607
689
|
return result;
|
|
608
690
|
const pages = new Map();
|
|
@@ -613,12 +695,21 @@ export function buyTool(options) {
|
|
|
613
695
|
// An answer with no store page leaves the last batch as it was.
|
|
614
696
|
if (pages.size === 0)
|
|
615
697
|
return result;
|
|
616
|
-
const
|
|
617
|
-
const
|
|
698
|
+
const kept = lastPages?.conversationId === result.conversation_id && (!call.presented || lastPages.turn === call.turn);
|
|
699
|
+
const before = kept && lastPages ? lastPages.urls : new Set();
|
|
700
|
+
// Agentcard names the search an answer lists by when it ran. An answer that lists fewer of the same
|
|
701
|
+
// search's products, such as a pick's, keeps every page that search already sent, so a later answer
|
|
702
|
+
// that lists them again sends none twice.
|
|
703
|
+
const catalog = result.envelope?.catalog;
|
|
704
|
+
const search = typeof catalog?.as_of === 'string' ? catalog.as_of : null;
|
|
705
|
+
const sameSearch = kept && search !== null && lastPages?.search === search;
|
|
706
|
+
const held = new Set(sameSearch ? before : []);
|
|
618
707
|
for (const [url, product] of pages) {
|
|
619
708
|
if (!before.has(url)) {
|
|
620
709
|
try {
|
|
621
|
-
|
|
710
|
+
// The id the model reads and a pick takes, never the store page: the page is `url`.
|
|
711
|
+
const productId = pickId(product);
|
|
712
|
+
await sendLink({ kind: 'product', url, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
|
|
622
713
|
}
|
|
623
714
|
catch {
|
|
624
715
|
continue;
|
|
@@ -626,25 +717,57 @@ export function buyTool(options) {
|
|
|
626
717
|
}
|
|
627
718
|
held.add(url);
|
|
628
719
|
}
|
|
629
|
-
lastPages = { conversationId: result.conversation_id, urls: held };
|
|
720
|
+
lastPages = { conversationId: result.conversation_id, turn: call.turn, search, urls: held };
|
|
630
721
|
return { ...result, products: result.products.map((product) => (product.url && held.has(product.url) ? { ...product, linkSent: true } : product)) };
|
|
631
722
|
};
|
|
723
|
+
/** The products a streamed search found, as an answer the page delivery reads: its conversation, its search and its products. */
|
|
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
|
+
};
|
|
632
755
|
/** One batch of pages at a time: a call that overlaps another reads the pages that one sent. */
|
|
633
756
|
let pagesInTurn = Promise.resolve();
|
|
634
|
-
const deliverPagesInTurn = (result) => {
|
|
635
|
-
const batch = pagesInTurn.then(() => deliverPages(result));
|
|
757
|
+
const deliverPagesInTurn = (result, call) => {
|
|
758
|
+
const batch = pagesInTurn.then(() => deliverPages(result, call)).then((delivered) => deliverStorePages(delivered, call));
|
|
636
759
|
pagesInTurn = batch.catch(() => { });
|
|
637
760
|
return batch;
|
|
638
761
|
};
|
|
639
762
|
/** The Vault or approval link first, because the purchase waits on it, then the store pages. */
|
|
640
|
-
const deliver = async (result) => {
|
|
763
|
+
const deliver = async (result, call) => {
|
|
641
764
|
let delivered = result;
|
|
642
765
|
if (result.links.length > 0 && sendLink) {
|
|
643
766
|
for (const link of result.links)
|
|
644
767
|
await sendLink({ ...link });
|
|
645
768
|
delivered = { ...result, link_sent: true };
|
|
646
769
|
}
|
|
647
|
-
return deliverPagesInTurn(delivered);
|
|
770
|
+
return deliverPagesInTurn(delivered, call);
|
|
648
771
|
};
|
|
649
772
|
const answer = async (raw) => {
|
|
650
773
|
const startedAt = Date.now();
|
|
@@ -654,8 +777,9 @@ export function buyTool(options) {
|
|
|
654
777
|
const ask = input.ask.trim();
|
|
655
778
|
if (!confirm && ask === '')
|
|
656
779
|
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
|
|
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;
|
|
659
783
|
const body = {
|
|
660
784
|
resume: true,
|
|
661
785
|
...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
|
|
@@ -664,19 +788,56 @@ export function buyTool(options) {
|
|
|
664
788
|
...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
|
|
665
789
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
666
790
|
};
|
|
667
|
-
//
|
|
668
|
-
|
|
791
|
+
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
792
|
+
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
793
|
+
// working through the same turn.
|
|
794
|
+
const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
|
|
795
|
+
appAck = null;
|
|
796
|
+
if (opened) {
|
|
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;
|
|
801
|
+
ackOut = null;
|
|
802
|
+
ackDelivered = opened.said;
|
|
803
|
+
modelTurn += 1;
|
|
804
|
+
}
|
|
805
|
+
else if (running === 0 && Date.now() - lastEndedAt >= SAME_TURN_MS) {
|
|
669
806
|
turnAcked = false;
|
|
807
|
+
neutralTurn = false;
|
|
670
808
|
ackOut = null;
|
|
809
|
+
ackDelivered = false;
|
|
810
|
+
modelTurn += 1;
|
|
671
811
|
}
|
|
812
|
+
const callTurn = modelTurn;
|
|
672
813
|
running += 1;
|
|
673
814
|
// 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);
|
|
815
|
+
const ack = confirm || pick ? null : holdAck(ask, neutralTurn);
|
|
675
816
|
try {
|
|
676
817
|
let turn = null;
|
|
677
818
|
let refusal;
|
|
819
|
+
// Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
|
|
820
|
+
const handlers = streams
|
|
821
|
+
? {
|
|
822
|
+
...(onProgress ? { onProgress: (progress) => { try {
|
|
823
|
+
onProgress(progress);
|
|
824
|
+
}
|
|
825
|
+
catch { /* the app's display failing never fails the call */ } } } : {}),
|
|
826
|
+
...(earlyCards && sendLink && !confirm
|
|
827
|
+
? {
|
|
828
|
+
onCatalog: async (update) => {
|
|
829
|
+
// Products arrived before the instant reply was due: they are the reply.
|
|
830
|
+
ack?.cancel();
|
|
831
|
+
await ackOut;
|
|
832
|
+
// The turn's own search presents its products, as a reply that lists them does.
|
|
833
|
+
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true });
|
|
834
|
+
},
|
|
835
|
+
}
|
|
836
|
+
: {}),
|
|
837
|
+
}
|
|
838
|
+
: undefined;
|
|
678
839
|
try {
|
|
679
|
-
turn = await client.buy(body);
|
|
840
|
+
turn = await client.buy(body, handlers);
|
|
680
841
|
}
|
|
681
842
|
catch (error) {
|
|
682
843
|
refusal = error;
|
|
@@ -684,7 +845,23 @@ export function buyTool(options) {
|
|
|
684
845
|
ack?.cancel();
|
|
685
846
|
const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
|
|
686
847
|
await ackOut;
|
|
687
|
-
|
|
848
|
+
const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true });
|
|
849
|
+
const result = ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
|
|
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);
|
|
856
|
+
if (onResult) {
|
|
857
|
+
try {
|
|
858
|
+
await onResult(result);
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// The app's display failing never fails the purchase call.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return result;
|
|
688
865
|
}
|
|
689
866
|
finally {
|
|
690
867
|
ack?.cancel();
|
|
@@ -711,7 +888,7 @@ export function buyTool(options) {
|
|
|
711
888
|
}
|
|
712
889
|
};
|
|
713
890
|
const { name, description, input_schema } = buyToolDefinition();
|
|
714
|
-
const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel };
|
|
891
|
+
const tool = { name, description, input_schema, strict: true, parse, run: handle, execute: handle, handle, answer, forModel, instantAck };
|
|
715
892
|
// JSON skips these three, so a runner that sends the tool to its API as is sends Anthropic's fields and nothing else.
|
|
716
893
|
const unsent = (value) => ({ value, enumerable: false, writable: true, configurable: true });
|
|
717
894
|
return Object.defineProperties(tool, {
|