@agent-cards/commerce-sdk 0.18.5 → 0.18.7
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 +34 -3
- package/dist/action-links.js +1 -0
- package/dist/app-instructions.d.ts +17 -0
- package/dist/app-instructions.generated.d.ts +9 -0
- package/dist/app-instructions.generated.js +10 -0
- package/dist/app-instructions.js +22 -0
- package/dist/buy-tool.d.ts +35 -3
- package/dist/buy-tool.js +96 -46
- package/dist/client.d.ts +92 -0
- package/dist/client.js +59 -2
- package/dist/definition.js +2 -1
- package/dist/direct.d.ts +8 -0
- package/dist/direct.js +13 -2
- package/dist/fulfilled-by.d.ts +34 -0
- package/dist/fulfilled-by.js +52 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/message.d.ts +2 -1
- package/dist/model-view.d.ts +55 -5
- package/dist/model-view.js +105 -16
- package/dist/plan.d.ts +41 -0
- package/dist/plan.js +34 -0
- package/dist/search-status.d.ts +72 -0
- package/dist/search-status.js +78 -0
- package/dist/store-wait.d.ts +27 -0
- package/dist/store-wait.js +41 -0
- package/dist/trip-ask.d.ts +1 -1
- package/dist/trip-ask.js +4 -4
- package/examples/anthropic.mjs +3 -2
- package/package.json +4 -3
package/dist/buy-tool.js
CHANGED
|
@@ -1,36 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The buy tool: one call that buys for one shopper, in one object that every
|
|
3
|
-
* agent framework takes. `answer` sends the model's input to Agentcard and
|
|
4
|
-
* returns the whole answer for your code, with the status, Agentcard's
|
|
5
|
-
* reply, the cart and its hash, the products and the links. `forModel` turns
|
|
6
|
-
* that answer into what the model may read, with every link taken out, and
|
|
7
|
-
* `run`, `execute` and `handle` return that view as JSON text. A link
|
|
8
|
-
* reaches the shopper through `sendLink`, and each call waits for it before
|
|
9
|
-
* it returns: the Vault and approval links, then the store page of each
|
|
10
|
-
* product the model reads.
|
|
11
|
-
*
|
|
12
|
-
* Agentcard keeps the conversation: every call continues the shopper's open
|
|
13
|
-
* purchase (`resume: true`), and a confirm places the cart Agentcard last
|
|
14
|
-
* showed (`confirm: true`). The tool keeps one thing between calls: the store
|
|
15
|
-
* pages it sent last. Agentcard repeats the last search in each answer for a
|
|
16
|
-
* while, so a list it shows again in the same conversation does not reach
|
|
17
|
-
* the shopper twice. A tool built for each message buys the same way, and
|
|
18
|
-
* sends such a list again.
|
|
19
|
-
*
|
|
20
|
-
* Two answers make the tool wait before it returns. A placement Agentcard is
|
|
21
|
-
* still finishing gets one 12-second pause and one read of the conversation.
|
|
22
|
-
* A turn that outlived Agentcard's budget gets a read every 5 seconds for up
|
|
23
|
-
* to a minute, until the turn ends.
|
|
24
|
-
*
|
|
25
|
-
* `turn` binds the tool to one shopper message: the calls the model makes
|
|
26
|
-
* through it hand their links, answers and instant reply to that turn's own
|
|
27
|
-
* callbacks and lists, so a shopper who sends a second message before the
|
|
28
|
-
* first is answered never gets one message's cards beside the other's reply.
|
|
29
|
-
* Every turn of one tool still shares the shopper's conversation state, such
|
|
30
|
-
* as the store pages already sent. A turn with `onItem` hands the app its
|
|
31
|
-
* messages and links as one sequence, in the order they happened, and spaces
|
|
32
|
-
* the messages like someone texting.
|
|
33
|
-
*/
|
|
34
1
|
import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
|
|
35
2
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
36
3
|
import { directCall, isProductAsk } from './direct.js';
|
|
@@ -39,6 +6,7 @@ import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, repl
|
|
|
39
6
|
import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
|
|
40
7
|
import { isStale, messageInChat } from './shopper-message.js';
|
|
41
8
|
import { actionLinkId, isActionLink } from './action-links.js';
|
|
9
|
+
import { checkStoreWait, storeWaitFields, withLateCards } from './store-wait.js';
|
|
42
10
|
import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
|
|
43
11
|
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
44
12
|
export function productAction(merchant) {
|
|
@@ -151,6 +119,8 @@ const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shoppe
|
|
|
151
119
|
const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
|
|
152
120
|
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.';
|
|
153
121
|
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.";
|
|
122
|
+
/** The most product cards a tool keeps from earlier answers for a tap on an older card. */
|
|
123
|
+
const SHOWN_BEFORE_MAX = 200;
|
|
154
124
|
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.";
|
|
155
125
|
/** One cart line as the order holds it: the product, its variant, its choices and its quantity. Prices are left out. */
|
|
156
126
|
const lineKey = (line) => JSON.stringify([line.product_id ?? String(line.name ?? '').trim().toLowerCase(), line.variant ?? null, [...(line.options ?? [])].sort(), line.qty]);
|
|
@@ -272,7 +242,7 @@ export function productsOf(turn) {
|
|
|
272
242
|
/** A product's card as `sendLink` hears it: the id the model reads and a pick takes, never the store page, which is `url`. A flight option has no page at a store: its card carries no url, and its productId picks it. */
|
|
273
243
|
function productLink(product) {
|
|
274
244
|
const productId = pickId(product);
|
|
275
|
-
return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), store: product.store ?? null, storeDomain: product.storeDomain ?? null, expiresAt: null, ...(product.availability ? { availability: product.availability } : {}) };
|
|
245
|
+
return { kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), store: product.store ?? null, storeDomain: product.storeDomain ?? null, expiresAt: null, ...(product.availability ? { availability: product.availability } : {}), ...(product.fulfilledBy ? { fulfilledBy: product.fulfilledBy } : {}) };
|
|
276
246
|
}
|
|
277
247
|
/** A host name: dot-separated labels of letters, digits and hyphens, ending in a letters-only top-level label. */
|
|
278
248
|
const STORE_HOST = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
@@ -308,6 +278,7 @@ function productsOfCatalog(catalog) {
|
|
|
308
278
|
action: productAction(catalog.merchant),
|
|
309
279
|
linkSent: false,
|
|
310
280
|
...(item.availability ? { availability: item.availability } : {}),
|
|
281
|
+
...(item.fulfilled_by ? { fulfilledBy: item.fulfilled_by } : {}),
|
|
311
282
|
}));
|
|
312
283
|
}
|
|
313
284
|
/** The stores an answer presents, as the tool reads them. */
|
|
@@ -352,6 +323,9 @@ function answerOf(turn) {
|
|
|
352
323
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
353
324
|
cart_building: turn.cart_building,
|
|
354
325
|
choice: turn.choice ?? null,
|
|
326
|
+
code_request: turn.code_request ?? null,
|
|
327
|
+
plan: turn.plan ?? null,
|
|
328
|
+
search: turn.search ?? null,
|
|
355
329
|
envelope: turn.raw,
|
|
356
330
|
};
|
|
357
331
|
}
|
|
@@ -419,6 +393,13 @@ const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:paym
|
|
|
419
393
|
* which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
|
|
420
394
|
* it, so the shopper reads them in order; a message before it goes out at once.
|
|
421
395
|
*/
|
|
396
|
+
/**
|
|
397
|
+
* Agentcard's messages of an answer, as written, that the shopper read in full through the early reply (onWrittenText)
|
|
398
|
+
* before the call ended, by the answer they belong to. `turn.replyAsWritten` hands them back for the turn's last answer and
|
|
399
|
+
* `turn.say` of them sends nothing, so the app shows no second reply and its model is not asked to word one
|
|
400
|
+
* (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model wrote a second recommendation of the same hotels).
|
|
401
|
+
*/
|
|
402
|
+
const readAsWritten = new WeakMap();
|
|
422
403
|
function waitsForAnswer(message) {
|
|
423
404
|
return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
|
|
424
405
|
}
|
|
@@ -771,6 +752,7 @@ export function buyTool(options) {
|
|
|
771
752
|
throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
|
|
772
753
|
}
|
|
773
754
|
const threadId = o.threadId?.trim();
|
|
755
|
+
checkStoreWait(o);
|
|
774
756
|
/** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
|
|
775
757
|
const inThisThread = (message) => messageInChat(message, threadId);
|
|
776
758
|
if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
|
|
@@ -788,7 +770,7 @@ export function buyTool(options) {
|
|
|
788
770
|
if (o.stream !== undefined && o.stream !== false)
|
|
789
771
|
throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
|
|
790
772
|
const onProgress = o.onProgress;
|
|
791
|
-
for (const key of ['onStatus', 'onFound', 'onAvailability']) {
|
|
773
|
+
for (const key of ['onStatus', 'onFound', 'onAvailability', 'onMerchantStatus']) {
|
|
792
774
|
if (o[key] !== undefined && typeof o[key] !== 'function')
|
|
793
775
|
throw new TypeError(`Pass ${key} as a function, or leave it out.`);
|
|
794
776
|
}
|
|
@@ -844,6 +826,9 @@ export function buyTool(options) {
|
|
|
844
826
|
const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
|
|
845
827
|
/** Calls running now, when the last one ended, and the template the instant reply used last. */
|
|
846
828
|
let running = 0;
|
|
829
|
+
// When the newest call of this tool started: a late reply on the conversation answers the newest shopper message, so a call
|
|
830
|
+
// that a newer one followed no longer owns it.
|
|
831
|
+
let newestCallAt = 0;
|
|
847
832
|
let lastEndedAt = Number.NEGATIVE_INFINITY;
|
|
848
833
|
let lastTemplate = -1;
|
|
849
834
|
/** Counts the model's turns: the calls of one turn share its number. */
|
|
@@ -866,6 +851,7 @@ export function buyTool(options) {
|
|
|
866
851
|
onStatus: o.onStatus,
|
|
867
852
|
onFound: o.onFound,
|
|
868
853
|
onAvailability: o.onAvailability,
|
|
854
|
+
onMerchantStatus: o.onMerchantStatus,
|
|
869
855
|
onProducts: o.onProducts,
|
|
870
856
|
onText: o.onText,
|
|
871
857
|
texting: textingByDefault,
|
|
@@ -942,6 +928,9 @@ export function buyTool(options) {
|
|
|
942
928
|
let lastAnswerProducts = [];
|
|
943
929
|
/** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
|
|
944
930
|
let lastTappable = [];
|
|
931
|
+
/** Every product card this conversation's answers showed, newest first, each once: a tap on an older card reads these. */
|
|
932
|
+
let shownBefore = [];
|
|
933
|
+
let shownIn = null;
|
|
945
934
|
let lastAnswerStores = [];
|
|
946
935
|
let previousTurn = null;
|
|
947
936
|
/** The names of the stores the last answer that listed stores showed: a message that names one, as a tap on its card does, picks it. */
|
|
@@ -1278,7 +1267,8 @@ export function buyTool(options) {
|
|
|
1278
1267
|
* picks one first. A placement another attempt is still finishing keeps the
|
|
1279
1268
|
* reading going and ends as `placing`, never as the cart. A minute without
|
|
1280
1269
|
* any of them returns Agentcard's own answer, or what the last read showed
|
|
1281
|
-
* while the turn still ran.
|
|
1270
|
+
* while the turn still ran. A turn that ended with a reply of its own
|
|
1271
|
+
* (`latest_reply`) answers with that reply and the products it showed.
|
|
1282
1272
|
*/
|
|
1283
1273
|
const awaitCart = async (base, conversationId, since) => {
|
|
1284
1274
|
const shown = new Set(base.hashes);
|
|
@@ -1313,13 +1303,18 @@ export function buyTool(options) {
|
|
|
1313
1303
|
// conversation and it shows one cart. With carts at two stores `confirm: true` still names the
|
|
1314
1304
|
// cart an earlier answer showed, so none is offered: the shopper's pick goes out as an ask.
|
|
1315
1305
|
const one = conversation.carts.length === 1 ? newest : null;
|
|
1306
|
+
// The reply the turn wrote after its early answer is what the shopper waited for: it goes out in place of the
|
|
1307
|
+
// tool's own line, with the products it showed (conv_054988b998634b16ab4a91d9, 2026-10-30: a stop message
|
|
1308
|
+
// written while the shopper read "one moment" was never shown, and the model was told to call again).
|
|
1309
|
+
const late = conversation.turn_in_progress || newestCallAt > since ? null : conversation.latest_reply;
|
|
1316
1310
|
const answer = {
|
|
1317
1311
|
...base,
|
|
1318
|
-
reply: one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
|
|
1312
|
+
reply: late ? late.text : one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
|
|
1313
|
+
...(late?.catalog ? { products: productsOfCatalog(late.catalog) } : {}),
|
|
1319
1314
|
cart: one,
|
|
1320
1315
|
carts: conversation.carts,
|
|
1321
1316
|
hashes: conversation.carts.map((c) => c.hash),
|
|
1322
|
-
error_code: newest ? null : base.error_code,
|
|
1317
|
+
error_code: newest || late ? null : base.error_code,
|
|
1323
1318
|
// A read replaced the answer the step was named for, and names no step of its own: the reply asks.
|
|
1324
1319
|
next_action: null,
|
|
1325
1320
|
// The early answer judged the pick before the turn finished adding it.
|
|
@@ -1818,6 +1813,9 @@ export function buyTool(options) {
|
|
|
1818
1813
|
let cardsOut = false;
|
|
1819
1814
|
/** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
|
|
1820
1815
|
const streamedCards = [];
|
|
1816
|
+
/** Whether Agentcard said the reply is written (`reply_written`), and the cards that went out after it: a store that answered late. */
|
|
1817
|
+
let replyWritten = false;
|
|
1818
|
+
const lateCards = [];
|
|
1821
1819
|
let heldCount = null;
|
|
1822
1820
|
/** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
|
|
1823
1821
|
const cardOfId = new Map();
|
|
@@ -1934,13 +1932,19 @@ export function buyTool(options) {
|
|
|
1934
1932
|
// The products past the cards are read after the cards went out, so the first card never waits for them.
|
|
1935
1933
|
const keepMore = () => { if (t.onFound && early)
|
|
1936
1934
|
moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
|
|
1937
|
-
if (!early)
|
|
1935
|
+
if (!early) {
|
|
1936
|
+
// Without early cards nothing goes out here, but a store that answered after the reply still joins the result.
|
|
1937
|
+
if (replyWritten)
|
|
1938
|
+
lateCards.push(...productsOfCatalog(update.catalog));
|
|
1938
1939
|
return;
|
|
1940
|
+
}
|
|
1939
1941
|
// Products arrived before the instant reply was due: they are the reply.
|
|
1940
1942
|
ack?.cancel();
|
|
1941
1943
|
await t.ackOut;
|
|
1942
1944
|
// The turn's own search presents its products, as a reply that lists them does.
|
|
1943
|
-
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1945
|
+
const out = await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1946
|
+
if (replyWritten)
|
|
1947
|
+
lateCards.push(...out.products);
|
|
1944
1948
|
// A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
|
|
1945
1949
|
noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
|
|
1946
1950
|
streamedCards.push(...productsOfCatalog(update.catalog));
|
|
@@ -1961,6 +1965,18 @@ export function buyTool(options) {
|
|
|
1961
1965
|
},
|
|
1962
1966
|
}
|
|
1963
1967
|
: {}),
|
|
1968
|
+
...(t.onMerchantStatus
|
|
1969
|
+
? {
|
|
1970
|
+
onMerchantStatus: async (update) => {
|
|
1971
|
+
try {
|
|
1972
|
+
await t.onMerchantStatus(update);
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
// The app's display failing never fails the turn.
|
|
1976
|
+
}
|
|
1977
|
+
},
|
|
1978
|
+
}
|
|
1979
|
+
: {}),
|
|
1964
1980
|
...(t.onText || writtenText
|
|
1965
1981
|
? {
|
|
1966
1982
|
onSegment: async ({ text, conversationId }) => {
|
|
@@ -1972,10 +1988,11 @@ export function buyTool(options) {
|
|
|
1972
1988
|
},
|
|
1973
1989
|
}
|
|
1974
1990
|
: {}),
|
|
1975
|
-
...(
|
|
1991
|
+
...(early || t.onProducts
|
|
1976
1992
|
? {
|
|
1977
1993
|
onReplyWritten: async () => {
|
|
1978
|
-
|
|
1994
|
+
replyWritten = true;
|
|
1995
|
+
if (speak || !writtenText)
|
|
1979
1996
|
return;
|
|
1980
1997
|
speak = writtenText;
|
|
1981
1998
|
for (const message of unsaid.splice(0))
|
|
@@ -2021,10 +2038,15 @@ export function buyTool(options) {
|
|
|
2021
2038
|
// Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
|
|
2022
2039
|
if (threadId)
|
|
2023
2040
|
sent = { ...sent, thread_id: threadId };
|
|
2041
|
+
// Every request names how long the reply waits for stores, and what happens to a store that answers after it.
|
|
2042
|
+
sent = { ...sent, ...storeWaitFields(t.storeWait ?? {}, o) };
|
|
2024
2043
|
if (plan && 'local' in plan) {
|
|
2025
2044
|
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
2026
2045
|
}
|
|
2027
2046
|
else {
|
|
2047
|
+
// This call now owns the conversation's newest reply: an earlier call still waiting for its late reply gives it up.
|
|
2048
|
+
// A call that threw on its input, or answered from where the purchase stands, sent nothing and takes none.
|
|
2049
|
+
newestCallAt = Math.max(newestCallAt, startedAt);
|
|
2028
2050
|
try {
|
|
2029
2051
|
turn = await client.buy(sent, handlers);
|
|
2030
2052
|
}
|
|
@@ -2119,8 +2141,14 @@ export function buyTool(options) {
|
|
|
2119
2141
|
}
|
|
2120
2142
|
}
|
|
2121
2143
|
const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
|
|
2144
|
+
// The shopper read the whole answer before the call ended, the late source's message included: the turn's reply is what
|
|
2145
|
+
// they read, and nothing is worded again (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model
|
|
2146
|
+
// wrote a second recommendation of the same hotels).
|
|
2122
2147
|
const answer = withMessages(settled, turn);
|
|
2123
|
-
|
|
2148
|
+
// A late reply read from the conversation presents the products of its own catalog, which the early answer never held.
|
|
2149
|
+
const late = settled.envelope?.latest_reply;
|
|
2150
|
+
const presented = turn?.catalog?.presented === true || (late?.text === settled.reply && late?.catalog?.presented === true);
|
|
2151
|
+
const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented, owner: t }, send);
|
|
2124
2152
|
// A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
|
|
2125
2153
|
if (heldCount && (delivered.products?.length ?? 0) > 0) {
|
|
2126
2154
|
const held = heldCount;
|
|
@@ -2145,11 +2173,15 @@ export function buyTool(options) {
|
|
|
2145
2173
|
: null;
|
|
2146
2174
|
const result = {
|
|
2147
2175
|
...delivered,
|
|
2176
|
+
// The cards of a store that answered after the reply join the answer's, when the answer does not list them already.
|
|
2177
|
+
...(lateCards.length > 0 ? { products: withLateCards(delivered.products, lateCards) } : {}),
|
|
2148
2178
|
...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
|
|
2149
2179
|
...(read ? { shopper_already_read_reply: true } : {}),
|
|
2150
2180
|
...(confirmSent ? { confirm_sent: true } : {}),
|
|
2151
2181
|
...(yesCarried ? { yes_carried: yesCarried } : {}),
|
|
2152
2182
|
};
|
|
2183
|
+
if (read && speak && speak === writtenText)
|
|
2184
|
+
readAsWritten.set(result, told.join('\n\n'));
|
|
2153
2185
|
lastNextAction = result.next_action;
|
|
2154
2186
|
lastReply = result.reply;
|
|
2155
2187
|
// A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
|
|
@@ -2199,6 +2231,13 @@ export function buyTool(options) {
|
|
|
2199
2231
|
}
|
|
2200
2232
|
lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
2201
2233
|
lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
2234
|
+
// A new conversation starts the cards shown before afresh; each card keeps its newest showing, up to SHOWN_BEFORE_MAX.
|
|
2235
|
+
if (result.conversation_id && result.conversation_id !== shownIn)
|
|
2236
|
+
shownBefore = [];
|
|
2237
|
+
if (result.conversation_id)
|
|
2238
|
+
shownIn = result.conversation_id;
|
|
2239
|
+
const fresh = new Set(lastTappable.map((product) => product.productId ?? `name:${product.name}`));
|
|
2240
|
+
shownBefore = [...lastTappable, ...shownBefore.filter((product) => !fresh.has(product.productId ?? `name:${product.name}`))].slice(0, SHOWN_BEFORE_MAX);
|
|
2202
2241
|
lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
|
|
2203
2242
|
t.results?.push(result);
|
|
2204
2243
|
if (t.onResult) {
|
|
@@ -2292,7 +2331,7 @@ export function buyTool(options) {
|
|
|
2292
2331
|
const own = options ?? {};
|
|
2293
2332
|
if (typeof own !== 'object')
|
|
2294
2333
|
throw new TypeError('Pass the turn options as an object, or leave them out.');
|
|
2295
|
-
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
2334
|
+
for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
|
|
2296
2335
|
if (own[key] !== undefined && typeof own[key] !== 'function')
|
|
2297
2336
|
throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
|
|
2298
2337
|
}
|
|
@@ -2310,6 +2349,7 @@ export function buyTool(options) {
|
|
|
2310
2349
|
if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
|
|
2311
2350
|
throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
|
|
2312
2351
|
const texting = own.textingStyle ?? textingByDefault;
|
|
2352
|
+
checkStoreWait(own, ' to the turn');
|
|
2313
2353
|
const hearText = own.onText ?? o.onText;
|
|
2314
2354
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
2315
2355
|
const pauseOf = paceOf(own.pace);
|
|
@@ -2375,6 +2415,7 @@ export function buyTool(options) {
|
|
|
2375
2415
|
onStatus: own.onStatus ?? o.onStatus,
|
|
2376
2416
|
onFound: own.onFound ?? o.onFound,
|
|
2377
2417
|
onAvailability: own.onAvailability ?? o.onAvailability,
|
|
2418
|
+
onMerchantStatus: own.onMerchantStatus ?? o.onMerchantStatus,
|
|
2378
2419
|
onProducts: own.onProducts ?? o.onProducts,
|
|
2379
2420
|
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
2380
2421
|
// an app that passes onItem too hears it once.
|
|
@@ -2386,6 +2427,7 @@ export function buyTool(options) {
|
|
|
2386
2427
|
? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : onMessage?.(text)))
|
|
2387
2428
|
: undefined,
|
|
2388
2429
|
texting,
|
|
2430
|
+
storeWait: { storeWaitMs: own.storeWaitMs, lateResults: own.lateResults },
|
|
2389
2431
|
onCart: own.onCart ?? o.onCart,
|
|
2390
2432
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
2391
2433
|
links: [],
|
|
@@ -2478,13 +2520,17 @@ export function buyTool(options) {
|
|
|
2478
2520
|
const results = state.results;
|
|
2479
2521
|
// A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
|
|
2480
2522
|
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
2523
|
+
// A tap names its card by the id its link carried, whatever answer showed it: the pick goes straight with that id.
|
|
2524
|
+
const tappedId = typeof options?.tapped?.productId === 'string' && options.tapped.productId.trim() ? options.tapped.productId.trim() : null;
|
|
2481
2525
|
// A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
|
|
2482
2526
|
// straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
|
|
2483
2527
|
const direct = !directOn || running > 0
|
|
2484
2528
|
? null
|
|
2485
2529
|
: event
|
|
2486
2530
|
? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
|
|
2487
|
-
:
|
|
2531
|
+
: tappedId && String(shopperText ?? '').trim()
|
|
2532
|
+
? { input: { ask: String(shopperText).trim(), confirm: false, new_order: false, product_id: tappedId, store_id: null }, reason: 'pick' }
|
|
2533
|
+
: directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, shownBefore, stores: lastAnswerStores });
|
|
2488
2534
|
// Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
|
|
2489
2535
|
// or the wait line below.
|
|
2490
2536
|
state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
|
|
@@ -2595,6 +2641,10 @@ export function buyTool(options) {
|
|
|
2595
2641
|
/** Says a whole text as the turn's messages, after every item before it (`say()`). */
|
|
2596
2642
|
const sayWhole = (whole) => {
|
|
2597
2643
|
writesThroughTurn = true;
|
|
2644
|
+
// The reply the shopper already read as Agentcard wrote it (`readAsWritten`), of the turn's last answer: saying it again sends nothing.
|
|
2645
|
+
const read = results.length > 0 ? readAsWritten.get(results[results.length - 1]) : undefined;
|
|
2646
|
+
if (read && whole === read)
|
|
2647
|
+
return releaseForReply();
|
|
2598
2648
|
firstWords(whole);
|
|
2599
2649
|
if (state.modelTurn && /\S/.test(String(whole ?? '')))
|
|
2600
2650
|
state.modelTurn.spoke = true;
|
|
@@ -2678,7 +2728,7 @@ export function buyTool(options) {
|
|
|
2678
2728
|
results: { get: () => [...results], enumerable: false },
|
|
2679
2729
|
direct: { value: direct, enumerable: false },
|
|
2680
2730
|
stopped: { get: () => state.stopped === true, enumerable: false },
|
|
2681
|
-
replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
|
|
2731
|
+
replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? readAsWritten.get(results[results.length - 1]) ?? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
|
|
2682
2732
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
2683
2733
|
// Each message joins the feed the moment it is cut, so a link the tool hands over during a pause never overtakes text already written.
|
|
2684
2734
|
// A step link held for this message goes out right after its last paragraph, once the stream ends.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client the buy tool calls Agentcard with. A company builds one from its
|
|
3
|
+
* client credentials, and every shopper's tool shares the one access token it
|
|
4
|
+
* exchanges them for; or a tool runs on one shopper's own access token. The
|
|
5
|
+
* global fetch is the only transport, so the package keeps no dependencies.
|
|
6
|
+
*
|
|
7
|
+
* The answer fields and the error codes follow the buy module of
|
|
8
|
+
* @agent-cards/sdk (packages/checkout/src/buy.ts: turnOf, buyError), and the
|
|
9
|
+
* token cache follows its client (packages/checkout/src/client.ts accessToken).
|
|
10
|
+
*/
|
|
11
|
+
import { type BuyPlan } from './plan.js';
|
|
12
|
+
import { type BuyMerchantStatusUpdate, type BuySearchStatus } from './search-status.js';
|
|
13
|
+
import { type BuyFulfilledBy } from './fulfilled-by.js';
|
|
1
14
|
import { type AgentcardListener, type ListenOptions } from './listen.js';
|
|
2
15
|
import { type ShopperMessage } from './shopper-message.js';
|
|
3
16
|
/** The fetch the client calls: Node 18 and later have one built in. */
|
|
@@ -151,6 +164,11 @@ export interface BuyStreamHandlers {
|
|
|
151
164
|
onReplyWritten?: (update: {
|
|
152
165
|
conversationId: string | null;
|
|
153
166
|
}) => void | Promise<void>;
|
|
167
|
+
/**
|
|
168
|
+
* Each store the turn searches, the moment its status changes: `searching` when its search starts, then what it found. To
|
|
169
|
+
* follow one store, keep the updates `isMerchantStatusAt(update, 'walmart.com')` accepts. The answer's `search` holds every store.
|
|
170
|
+
*/
|
|
171
|
+
onMerchantStatus?: (update: BuyMerchantStatusUpdate) => void | Promise<void>;
|
|
154
172
|
}
|
|
155
173
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
156
174
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
@@ -241,6 +259,16 @@ export interface BuyRequest {
|
|
|
241
259
|
* another thread is refused (409 `thread_mismatch`). Leave it out for an app with one chat per shopper.
|
|
242
260
|
*/
|
|
243
261
|
thread_id?: string;
|
|
262
|
+
/**
|
|
263
|
+
* How long, in milliseconds, the reply waits for store searches before Agentcard writes it. Left out, Agentcard keeps
|
|
264
|
+
* its own wait. `buyTool({ storeWaitMs })` sends it on every request.
|
|
265
|
+
*/
|
|
266
|
+
store_wait_ms?: number;
|
|
267
|
+
/**
|
|
268
|
+
* What happens to a store that answers after the reply: `stream` sends its cards after the reply in the same streamed
|
|
269
|
+
* turn, before the answer; `hold` keeps them out of the turn. Left out, Agentcard does as it does today.
|
|
270
|
+
*/
|
|
271
|
+
late_results?: 'stream' | 'hold';
|
|
244
272
|
/** Drop the cart on the table and start a new order. */
|
|
245
273
|
new_order?: true;
|
|
246
274
|
/**
|
|
@@ -325,6 +353,8 @@ export interface BuyCartLine {
|
|
|
325
353
|
ref: string | null;
|
|
326
354
|
/** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
|
|
327
355
|
url: string | null;
|
|
356
|
+
/** Who sells or books the line, where that is not the store the cart names (`via StubHub`); absent when Agentcard named none. */
|
|
357
|
+
fulfilled_by?: BuyFulfilledBy;
|
|
328
358
|
}
|
|
329
359
|
/**
|
|
330
360
|
* A cart as Agentcard answers it. `totalCents` is the all-in figure the shopper sees; `hash` is what a confirm names.
|
|
@@ -335,6 +365,25 @@ export interface BuyCartFeeLine {
|
|
|
335
365
|
label: string;
|
|
336
366
|
amountCents: number;
|
|
337
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* A discount or a coupon inside a cart's total, as the store labels it. `amountCents` is negative. `kind` is `discount`
|
|
370
|
+
* for one the store applied on its own, such as a promotion, and `coupon` for a code entered at the store's checkout,
|
|
371
|
+
* which `code` names; `code` is null for a discount.
|
|
372
|
+
*/
|
|
373
|
+
export interface BuyCartDiscount {
|
|
374
|
+
label: string;
|
|
375
|
+
amountCents: number;
|
|
376
|
+
kind: 'discount' | 'coupon';
|
|
377
|
+
code: string | null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Why a cart's card is held for `amountCents`: `flight` and `hotel` for the hold a booking places on the card, and
|
|
381
|
+
* `checkout_estimate` for the headroom above a store's total that covers tax and shipping and is released unused.
|
|
382
|
+
*/
|
|
383
|
+
export interface BuyCartHold {
|
|
384
|
+
amountCents: number;
|
|
385
|
+
reason: 'flight' | 'hotel' | 'checkout_estimate';
|
|
386
|
+
}
|
|
338
387
|
export interface BuyCart {
|
|
339
388
|
merchant: string;
|
|
340
389
|
merchant_name: string;
|
|
@@ -346,6 +395,10 @@ export interface BuyCart {
|
|
|
346
395
|
serviceFeesStatus?: 'known' | 'pending' | 'unknown';
|
|
347
396
|
/** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
|
|
348
397
|
merchantFeeLines: BuyCartFeeLine[] | null;
|
|
398
|
+
/** The discounts and coupons inside `totalCents`, which `merchantFeeLines` also lists. Empty when there are none, and from an older API. */
|
|
399
|
+
discounts?: BuyCartDiscount[];
|
|
400
|
+
/** True while the store's checkout builds this cart again, so `totalCents` is not its price yet. False from an older API. */
|
|
401
|
+
pricing?: boolean;
|
|
349
402
|
tipCents: number;
|
|
350
403
|
totalCents: number;
|
|
351
404
|
/**
|
|
@@ -356,6 +409,8 @@ export interface BuyCart {
|
|
|
356
409
|
/** The ISO 4217 code the store prices the cart in, or null when Agentcard sent none. */
|
|
357
410
|
currency: string | null;
|
|
358
411
|
approvedCeilingCents: number | null;
|
|
412
|
+
/** Why the card is held for `approvedCeilingCents`, or null when nothing is held or the API is older. */
|
|
413
|
+
hold?: BuyCartHold | null;
|
|
359
414
|
hash: string;
|
|
360
415
|
}
|
|
361
416
|
/** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
|
|
@@ -392,6 +447,8 @@ export interface BuyCatalogItem {
|
|
|
392
447
|
url: string | null;
|
|
393
448
|
/** Whether a place has room at the asked time (BuyAvailability). Absent on a product with no such question. */
|
|
394
449
|
availability?: BuyAvailability;
|
|
450
|
+
/** Who sells or books the product, where that is not the store the card names (`booked with OpenTable`); absent when Agentcard named none. */
|
|
451
|
+
fulfilled_by?: BuyFulfilledBy;
|
|
395
452
|
}
|
|
396
453
|
/**
|
|
397
454
|
* The products the last search found. `presented` is true when this answer's reply presents them: the
|
|
@@ -489,6 +546,19 @@ export interface BuyPaymentSource {
|
|
|
489
546
|
* (the code a booking texted them), then `approve` (the approval link). A value this version does
|
|
490
547
|
* not know reads as null.
|
|
491
548
|
*/
|
|
549
|
+
/**
|
|
550
|
+
* A store checkout waiting for the one-time code Stripe Link or Shop Pay sent the shopper: which wallet, whether it was a
|
|
551
|
+
* text or an email, and where it went, masked to a phone's last two digits ("••21") or an email's first letter and domain
|
|
552
|
+
* ("j•••@example.com"). The shopper sends the code as their next message, as `next_action: 'verification_code'` says.
|
|
553
|
+
*/
|
|
554
|
+
export interface BuyCodeRequest {
|
|
555
|
+
store: string;
|
|
556
|
+
wallet: 'link' | 'shop_pay';
|
|
557
|
+
channel: 'text' | 'email';
|
|
558
|
+
destination: string | null;
|
|
559
|
+
}
|
|
560
|
+
/** An answer's code request, from its `browser_checkout` when that checkout waits on a wallet's code; null otherwise. */
|
|
561
|
+
export declare function codeRequestOf(value: unknown): BuyCodeRequest | null;
|
|
492
562
|
export type BuyNextAction = 'shipping_details' | 'guest_details' | 'store_login' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
|
|
493
563
|
/**
|
|
494
564
|
* A cart the store's checkout is still building after an answer went out, at `store`, since `started_at`: its cart and
|
|
@@ -517,6 +587,8 @@ export interface BuyTurn {
|
|
|
517
587
|
approval_url: string | null;
|
|
518
588
|
charge_status: string | null;
|
|
519
589
|
error_code: string | null;
|
|
590
|
+
/** The party of the purchase this answer says failed, such as StubHub for a ticket, so an app names who stopped it; null when none is named. */
|
|
591
|
+
fulfilled_by: BuyFulfilledBy | null;
|
|
520
592
|
authorization_id: string | null;
|
|
521
593
|
merchant_total_cents: number | null;
|
|
522
594
|
merchant_currency: string | null;
|
|
@@ -527,6 +599,8 @@ export interface BuyTurn {
|
|
|
527
599
|
approval_in_progress: boolean | null;
|
|
528
600
|
/** The one step the shopper takes next, or null when nothing waits on them or Agentcard named none. */
|
|
529
601
|
next_action: BuyNextAction | null;
|
|
602
|
+
/** The wallet's code a store checkout waits for, with where it went, or null when none waits (BuyCodeRequest). */
|
|
603
|
+
code_request: BuyCodeRequest | null;
|
|
530
604
|
/** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
|
|
531
605
|
store_login_url: string | null;
|
|
532
606
|
store_login_expires_at: string | null;
|
|
@@ -542,6 +616,10 @@ export interface BuyTurn {
|
|
|
542
616
|
cart_building: BuyCartBuilding | null;
|
|
543
617
|
/** The choice the reply asks the shopper to make, each group with every value; null when it asks none, and on an older API. */
|
|
544
618
|
choice: BuyChoice | null;
|
|
619
|
+
/** The checkout plan this answer's yes started or moved, with each item's state; null when none, and on an older API. */
|
|
620
|
+
plan: BuyPlan | null;
|
|
621
|
+
/** Every store the turn searched and where each stands; null for a turn that searched none, and on an older API. */
|
|
622
|
+
search: BuySearchStatus | null;
|
|
545
623
|
/** The answer as Agentcard sent it. */
|
|
546
624
|
raw: Record<string, unknown>;
|
|
547
625
|
}
|
|
@@ -581,8 +659,22 @@ export interface BuyConversation {
|
|
|
581
659
|
last_checkout: BuyLastCheckout | null;
|
|
582
660
|
orders: BuyOrder[];
|
|
583
661
|
carts: BuyCart[];
|
|
662
|
+
/**
|
|
663
|
+
* The reply Agentcard wrote since the shopper's last message, once no turn runs: the answer a call that ended with
|
|
664
|
+
* `turn_timeout` never got. Null while a turn runs, when no reply was written since, and on an older API.
|
|
665
|
+
*/
|
|
666
|
+
latest_reply: BuyLatestReply | null;
|
|
584
667
|
raw: Record<string, unknown>;
|
|
585
668
|
}
|
|
669
|
+
/** A reply a conversation read hands back: its text, its messages, and the products it showed. */
|
|
670
|
+
export interface BuyLatestReply {
|
|
671
|
+
message_id: string;
|
|
672
|
+
text: string;
|
|
673
|
+
messages: string[];
|
|
674
|
+
/** The products the reply showed, when the call that asked ended before it was written; null otherwise. */
|
|
675
|
+
catalog: BuyCatalog | null;
|
|
676
|
+
created_at: string | null;
|
|
677
|
+
}
|
|
586
678
|
/** A Vault link: the page where a shopper connects and adds a card. */
|
|
587
679
|
export interface VaultLink {
|
|
588
680
|
id: string;
|