@agent-cards/commerce-sdk 0.18.4 → 0.18.5
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 +12 -0
- package/dist/buy-tool.d.ts +7 -0
- package/dist/buy-tool.js +76 -26
- package/dist/client.d.ts +47 -11
- package/dist/client.js +85 -15
- package/dist/shopper-message.d.ts +12 -0
- package/dist/shopper-message.js +10 -0
- package/dist/turn-record.d.ts +2 -2
- package/dist/turn-record.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -328,6 +328,16 @@ for await (const stream of runner) {
|
|
|
328
328
|
- A step link, the `vault`, `approval` or `store_login` page the shopper opens to go on, comes after the message that asks for it: the turn holds it until your model's next message stream ends (or `turn.say` is called), then sends it right after that message, so the shopper reads "add your card to place the order" and then taps. Product and store cards go out the moment the tool hands them over. A held link also goes out when the turn's next call starts, or after 20 seconds, so a model that writes nothing still sends it. `turn.say` and the stream's `end` resolve once the held links went out too, and reject when one failed to send, so your app never answers without a link the shopper needs; a link that failed to go out at the next call or the 20 seconds rejects the next reply that ends.
|
|
329
329
|
- Without `onItem`, pass `onMessage` beside `sendLink`, and write the model's text through `turn.messageStream()`: `onMessage` hears each message, paced, and `sendLink` hears each step link after the message that asks for it. An app that streams the model through the package's own `messageStream` gets each link the moment the tool hands it over, before the words about it.
|
|
330
330
|
|
|
331
|
+
## Keep one purchase per chat
|
|
332
|
+
|
|
333
|
+
A shopper who writes to your agent in several chats at once, such as group chats or threads, would otherwise continue one purchase from all of them. Build one tool per shopper and chat, and name the chat with `threadId`, your own id for it. Agentcard then keeps a purchase per chat: each chat's carts, questions and approvals stay in that chat, while the shopper keeps one Vault card, one store sign-in and one address book.
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
const buy = buyTool({ agentcard, externalUserId: shopper.id, threadId: chat.id }); // one per shopper and chat
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
A message about one chat's purchase carries that chat as `threadId`, so post it into that chat. One about the shopper alone, such as a saved card, carries `threadId: null`. A chat's tool takes a next step only for its own chat's messages and the shopper's own, and a tool built without `threadId` only for purchases opened under no thread and the shopper's own.
|
|
340
|
+
|
|
331
341
|
## Tell the model what Agentcard told the shopper
|
|
332
342
|
|
|
333
343
|
Agentcard posts messages into the shopper's chat between turns, through `listen` or the webhook: `Your Visa ending 3132 is saved, so you're ready to check out.`, an order placed, an approval about to expire. The shopper reads them, so your model has to know them too. A model that never saw `card_added` answers "ok lets checkout" with "did you save your card?" and never calls the tool.
|
|
@@ -517,6 +527,8 @@ const buy = buyTool({
|
|
|
517
527
|
|
|
518
528
|
When your app shows Agentcard's messages through `onText`, the model reads `shopper_already_read_reply: true` in that call's answer and adds nothing to them, as it reads `shopper_already_told_looking` after an instant reply: end the turn without a second model call, as with `replyAsWritten`, or let the model add one short line for what the reply left out. `turn.replyAsWritten` stays null for that answer, so the reply never reaches the shopper twice, and a step link the turn holds for your model's next message goes out right after Agentcard's ask instead.
|
|
519
529
|
|
|
530
|
+
A turn built with `replyAsWritten` and `earlyCards` that hears messages through `onMessage` or `onItem` and passes no `onText` gets the same once a reply is written while a slower source's cards are still coming, such as a second hotel supplier's: Agentcard's messages go out through that callback the moment Agentcard says the reply is written, the slower cards and their one message follow in the same call, and the answer reads `shopper_already_read_reply: true` with `turn.replyAsWritten` null.
|
|
531
|
+
|
|
520
532
|
A stream that breaks after the call started reads the conversation for what the turn left, as a turn past Agentcard's budget does. Pass `stream: false` to wait for each whole answer, with its cards sent beside it.
|
|
521
533
|
|
|
522
534
|
Code that calls the client itself streams with `agentcard.buy(body, handlers)`: `onCatalog`, `onSegment`, `onCart` and `onDelta` hear their events in the order Agentcard sent them, each once the one before it settled, and the answer waits for the last of them. `onDelta` asks Agentcard for the pieces of each message as it writes them, a draft that each `onSegment` message replaces.
|
package/dist/buy-tool.d.ts
CHANGED
|
@@ -296,6 +296,13 @@ export interface BuyToolOptions {
|
|
|
296
296
|
accessToken?: string;
|
|
297
297
|
/** Your own id for the shopper. Required with a company client, and left out with a shopper's own token. */
|
|
298
298
|
externalUserId?: string;
|
|
299
|
+
/**
|
|
300
|
+
* Your own id for the chat or thread this tool answers in, such as a group chat's id. Agentcard keeps one open purchase
|
|
301
|
+
* per thread for the shopper, so two chats never share a cart, a question or an approval, and the tool reads only the
|
|
302
|
+
* messages about this thread's purchase and those about the shopper alone, such as a saved card. Build one tool per
|
|
303
|
+
* shopper and thread. Leave it out for an app with one chat per shopper.
|
|
304
|
+
*/
|
|
305
|
+
threadId?: string;
|
|
299
306
|
/**
|
|
300
307
|
* Delivers a link to the shopper. Each call waits for it, so the shopper
|
|
301
308
|
* holds the link before the model answers. A `sendLink` that throws on a
|
package/dist/buy-tool.js
CHANGED
|
@@ -37,7 +37,7 @@ import { directCall, isProductAsk } from './direct.js';
|
|
|
37
37
|
import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
38
38
|
import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
|
|
39
39
|
import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
|
|
40
|
-
import { isStale } from './shopper-message.js';
|
|
40
|
+
import { isStale, messageInChat } from './shopper-message.js';
|
|
41
41
|
import { actionLinkId, isActionLink } from './action-links.js';
|
|
42
42
|
import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
|
|
43
43
|
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
@@ -81,7 +81,7 @@ export function handedNote(answer) {
|
|
|
81
81
|
}
|
|
82
82
|
/** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
|
|
83
83
|
const modelText = (message) => stripLinks(String(message.text ?? ''));
|
|
84
|
-
/** How often
|
|
84
|
+
/** How often each chat's history held a message's text when the message first went in to its model, by `threadId` ('' for none). */
|
|
85
85
|
const offeredAt = new WeakMap();
|
|
86
86
|
/** How many times `needle` appears in `haystack`, none overlapping. */
|
|
87
87
|
function occurrences(haystack, needle) {
|
|
@@ -767,6 +767,12 @@ export function buyTool(options) {
|
|
|
767
767
|
if (!client.company && externalUserId !== undefined) {
|
|
768
768
|
throw new TypeError("A shopper's own access token already names the shopper. Leave externalUserId out.");
|
|
769
769
|
}
|
|
770
|
+
if (o.threadId !== undefined && (typeof o.threadId !== 'string' || o.threadId.trim() === '' || o.threadId.trim().length > 200)) {
|
|
771
|
+
throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
|
|
772
|
+
}
|
|
773
|
+
const threadId = o.threadId?.trim();
|
|
774
|
+
/** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
|
|
775
|
+
const inThisThread = (message) => messageInChat(message, threadId);
|
|
770
776
|
if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
|
|
771
777
|
throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
|
|
772
778
|
}
|
|
@@ -1750,6 +1756,16 @@ export function buyTool(options) {
|
|
|
1750
1756
|
let refusal;
|
|
1751
1757
|
/** Each message of Agentcard's answer the app showed through onText, in the order the shopper read them: every one means the shopper read the reply. */
|
|
1752
1758
|
const told = [];
|
|
1759
|
+
/**
|
|
1760
|
+
* Where this call's messages from Agentcard go as they stream: the app's onText, or, for an app that shows the reply as
|
|
1761
|
+
* written, its onMessage once Agentcard says the reply is written (`reply_written`). Until then such an app's
|
|
1762
|
+
* messages are kept in `unsaid`, and a call whose stream never says so shows none of them: the app shows the answer.
|
|
1763
|
+
*/
|
|
1764
|
+
let speak = t.onText;
|
|
1765
|
+
// Only where the cards went out as they streamed (earlyCards): a reply shown earlier than its cards would ask the shopper
|
|
1766
|
+
// to choose from hotels they cannot see yet.
|
|
1767
|
+
const writtenText = early ? t.onWrittenText : undefined;
|
|
1768
|
+
const unsaid = [];
|
|
1753
1769
|
/**
|
|
1754
1770
|
* The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
|
|
1755
1771
|
* and every message after it, so the shopper reads them in the order Agentcard wrote them.
|
|
@@ -1762,7 +1778,7 @@ export function buyTool(options) {
|
|
|
1762
1778
|
let heldTexts = [];
|
|
1763
1779
|
/** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
|
|
1764
1780
|
const showMessage = async (message, texts, conversationId) => {
|
|
1765
|
-
await
|
|
1781
|
+
await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
|
|
1766
1782
|
leadIn = leadsIntoList(message);
|
|
1767
1783
|
told.push(...texts);
|
|
1768
1784
|
};
|
|
@@ -1863,6 +1879,23 @@ export function buyTool(options) {
|
|
|
1863
1879
|
// The app's display failing never fails the call.
|
|
1864
1880
|
}
|
|
1865
1881
|
};
|
|
1882
|
+
/** One of Agentcard's messages as it streams, to the app through `speak`. */
|
|
1883
|
+
const streamText = async (text, conversationId) => {
|
|
1884
|
+
// A message that lands before the instant reply was due is the reply; one after it follows it.
|
|
1885
|
+
ack?.cancel();
|
|
1886
|
+
await t.ackOut;
|
|
1887
|
+
// A card ask or a link waits for the answer, which may name a step that comes first, and every message after it waits behind it.
|
|
1888
|
+
if (waiting.length > 0 || waitsForAnswer(text)) {
|
|
1889
|
+
waiting.push({ text, conversationId });
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
try {
|
|
1893
|
+
await showText(text, conversationId);
|
|
1894
|
+
}
|
|
1895
|
+
catch {
|
|
1896
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1866
1899
|
// Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
|
|
1867
1900
|
// answers, and each message and cart to the app as Agentcard writes it.
|
|
1868
1901
|
const handlers = streams
|
|
@@ -1928,23 +1961,25 @@ export function buyTool(options) {
|
|
|
1928
1961
|
},
|
|
1929
1962
|
}
|
|
1930
1963
|
: {}),
|
|
1931
|
-
...(t.onText
|
|
1964
|
+
...(t.onText || writtenText
|
|
1932
1965
|
? {
|
|
1933
1966
|
onSegment: async ({ text, conversationId }) => {
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
await t.ackOut;
|
|
1937
|
-
// A card ask or a link waits for the answer, which may name a step that comes first, and every message after it waits behind it.
|
|
1938
|
-
if (waiting.length > 0 || waitsForAnswer(text)) {
|
|
1939
|
-
waiting.push({ text, conversationId });
|
|
1967
|
+
if (!speak) {
|
|
1968
|
+
unsaid.push({ text, conversationId });
|
|
1940
1969
|
return;
|
|
1941
1970
|
}
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1971
|
+
await streamText(text, conversationId);
|
|
1972
|
+
},
|
|
1973
|
+
}
|
|
1974
|
+
: {}),
|
|
1975
|
+
...(!t.onText && writtenText
|
|
1976
|
+
? {
|
|
1977
|
+
onReplyWritten: async () => {
|
|
1978
|
+
if (speak)
|
|
1979
|
+
return;
|
|
1980
|
+
speak = writtenText;
|
|
1981
|
+
for (const message of unsaid.splice(0))
|
|
1982
|
+
await streamText(message.text, message.conversationId);
|
|
1948
1983
|
},
|
|
1949
1984
|
}
|
|
1950
1985
|
: {}),
|
|
@@ -1983,6 +2018,9 @@ export function buyTool(options) {
|
|
|
1983
2018
|
// A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
|
|
1984
2019
|
const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
|
|
1985
2020
|
sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
|
|
2021
|
+
// Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
|
|
2022
|
+
if (threadId)
|
|
2023
|
+
sent = { ...sent, thread_id: threadId };
|
|
1986
2024
|
if (plan && 'local' in plan) {
|
|
1987
2025
|
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1988
2026
|
}
|
|
@@ -2003,6 +2041,8 @@ export function buyTool(options) {
|
|
|
2003
2041
|
// Only the answer of the request sent again counts toward what the shopper read.
|
|
2004
2042
|
told.length = 0;
|
|
2005
2043
|
waiting.length = 0;
|
|
2044
|
+
unsaid.length = 0;
|
|
2045
|
+
speak = t.onText;
|
|
2006
2046
|
leads = leadInJoiner();
|
|
2007
2047
|
heldTexts = [];
|
|
2008
2048
|
try {
|
|
@@ -2029,7 +2069,7 @@ export function buyTool(options) {
|
|
|
2029
2069
|
// a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
|
|
2030
2070
|
// before any link of the answer.
|
|
2031
2071
|
let replaced = false;
|
|
2032
|
-
if (waiting.length > 0 &&
|
|
2072
|
+
if (waiting.length > 0 && speak) {
|
|
2033
2073
|
const rewritten = !!turn && settled.reply !== turn.reply;
|
|
2034
2074
|
// Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
|
|
2035
2075
|
const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
|
|
@@ -2055,7 +2095,7 @@ export function buyTool(options) {
|
|
|
2055
2095
|
}
|
|
2056
2096
|
}
|
|
2057
2097
|
}
|
|
2058
|
-
if (
|
|
2098
|
+
if (speak) {
|
|
2059
2099
|
try {
|
|
2060
2100
|
await showLeftLead();
|
|
2061
2101
|
}
|
|
@@ -2068,10 +2108,10 @@ export function buyTool(options) {
|
|
|
2068
2108
|
// A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
|
|
2069
2109
|
// by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
|
|
2070
2110
|
let choiceShown = true;
|
|
2071
|
-
if (
|
|
2111
|
+
if (speak && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
|
|
2072
2112
|
const lines = choiceLines(turn.choice);
|
|
2073
2113
|
try {
|
|
2074
|
-
await
|
|
2114
|
+
await speak(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
|
|
2075
2115
|
}
|
|
2076
2116
|
catch {
|
|
2077
2117
|
// Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
|
|
@@ -2235,6 +2275,8 @@ export function buyTool(options) {
|
|
|
2235
2275
|
return null;
|
|
2236
2276
|
if (client.company && message.externalUserId !== externalUserId)
|
|
2237
2277
|
return null;
|
|
2278
|
+
if (!inThisThread(message))
|
|
2279
|
+
return null;
|
|
2238
2280
|
if (message.eventId && stepped.has(message.eventId))
|
|
2239
2281
|
return null;
|
|
2240
2282
|
// A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
|
|
@@ -2243,7 +2285,7 @@ export function buyTool(options) {
|
|
|
2243
2285
|
if (message.eventId)
|
|
2244
2286
|
stepped.add(message.eventId);
|
|
2245
2287
|
if (client.company)
|
|
2246
|
-
client.answered(externalUserId, message);
|
|
2288
|
+
client.answered(externalUserId, message, threadId);
|
|
2247
2289
|
return openTurn(nextStepPrompt(message), options, message);
|
|
2248
2290
|
};
|
|
2249
2291
|
const openTurn = (shopperText, options, event) => {
|
|
@@ -2337,6 +2379,12 @@ export function buyTool(options) {
|
|
|
2337
2379
|
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
2338
2380
|
// an app that passes onItem too hears it once.
|
|
2339
2381
|
onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
|
|
2382
|
+
// An app that shows the reply as written hears it the moment Agentcard says it is written, when the turn then waits for
|
|
2383
|
+
// a slower source's cards: the reply no longer waits for them (chat.opencommerce.sh, 2026-10-29, a hotel reply written
|
|
2384
|
+
// at about 21 s reached the shopper at 36.5 s, with the second supplier's hotels).
|
|
2385
|
+
onWrittenText: !hearText && written && (onItem || onMessage)
|
|
2386
|
+
? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : onMessage?.(text)))
|
|
2387
|
+
: undefined,
|
|
2340
2388
|
texting,
|
|
2341
2389
|
onCart: own.onCart ?? o.onCart,
|
|
2342
2390
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
@@ -2504,7 +2552,7 @@ export function buyTool(options) {
|
|
|
2504
2552
|
if (!event)
|
|
2505
2553
|
previousTurn = state;
|
|
2506
2554
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
2507
|
-
const kept = client.company ? client.keptMessages(externalUserId) : [];
|
|
2555
|
+
const kept = client.company ? client.keptMessages(externalUserId, threadId).filter(inThisThread) : [];
|
|
2508
2556
|
let handed = null;
|
|
2509
2557
|
const shopperWords = String(shopperText ?? '');
|
|
2510
2558
|
/**
|
|
@@ -2526,18 +2574,20 @@ export function buyTool(options) {
|
|
|
2526
2574
|
// Another order's update can read the same, so only a history holding the text more often than
|
|
2527
2575
|
// when this message first went in holds this message.
|
|
2528
2576
|
const count = occurrences(held, JSON.stringify(text).slice(1, -1));
|
|
2529
|
-
|
|
2577
|
+
// Each chat counts its own history: a message about the shopper alone goes in to every chat's model.
|
|
2578
|
+
const counts = offeredAt.get(message) ?? new Map();
|
|
2579
|
+
const before = counts.get(threadId ?? '');
|
|
2530
2580
|
if (before !== undefined && count > before) {
|
|
2531
2581
|
saved.push(message);
|
|
2532
2582
|
}
|
|
2533
2583
|
else {
|
|
2534
2584
|
if (before === undefined)
|
|
2535
|
-
offeredAt.set(message, count);
|
|
2585
|
+
offeredAt.set(message, counts.set(threadId ?? '', count));
|
|
2536
2586
|
fresh.push(message);
|
|
2537
2587
|
}
|
|
2538
2588
|
}
|
|
2539
2589
|
if (saved.length > 0)
|
|
2540
|
-
client.forgetMessages(externalUserId, saved);
|
|
2590
|
+
client.forgetMessages(externalUserId, saved, threadId);
|
|
2541
2591
|
handed = fresh;
|
|
2542
2592
|
// A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
|
|
2543
2593
|
return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
|
|
@@ -2613,7 +2663,7 @@ export function buyTool(options) {
|
|
|
2613
2663
|
if (event.eventId)
|
|
2614
2664
|
stepped.delete(event.eventId);
|
|
2615
2665
|
if (client.company)
|
|
2616
|
-
client.unanswered(externalUserId, event);
|
|
2666
|
+
client.unanswered(externalUserId, event, threadId);
|
|
2617
2667
|
},
|
|
2618
2668
|
enumerable: false,
|
|
2619
2669
|
},
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AgentcardListener, type ListenOptions } from './listen.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type ShopperMessage } from './shopper-message.js';
|
|
3
3
|
/** The fetch the client calls: Node 18 and later have one built in. */
|
|
4
4
|
export type FetchLike = (url: string, init: {
|
|
5
5
|
method: string;
|
|
@@ -144,6 +144,13 @@ export interface BuyStreamHandlers {
|
|
|
144
144
|
onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
|
|
145
145
|
/** Whether the places the turn's cards show have room, once Agentcard's live search says, after the cards it changes. */
|
|
146
146
|
onAvailability?: (update: BuyAvailabilityUpdate) => void | Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* The reply is written, after its messages, and the turn stays open only for a slower source's cards, such as a second
|
|
149
|
+
* hotel supplier's, and the one message that says what they added. Comes once, or not at all when nothing is still out.
|
|
150
|
+
*/
|
|
151
|
+
onReplyWritten?: (update: {
|
|
152
|
+
conversationId: string | null;
|
|
153
|
+
}) => void | Promise<void>;
|
|
147
154
|
}
|
|
148
155
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
149
156
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
@@ -228,6 +235,12 @@ export interface BuyRequest {
|
|
|
228
235
|
resume?: true;
|
|
229
236
|
/** The conversation a confirm of a cart hash belongs to, in place of `resume`. */
|
|
230
237
|
conversation_id?: string;
|
|
238
|
+
/**
|
|
239
|
+
* Your own id for the chat or thread the shopper wrote in, such as a group chat's id (up to 200 characters). The shopper
|
|
240
|
+
* keeps one open purchase per thread: `resume` continues the newest one in this thread only, and a conversation_id from
|
|
241
|
+
* another thread is refused (409 `thread_mismatch`). Leave it out for an app with one chat per shopper.
|
|
242
|
+
*/
|
|
243
|
+
thread_id?: string;
|
|
231
244
|
/** Drop the cart on the table and start a new order. */
|
|
232
245
|
new_order?: true;
|
|
233
246
|
/**
|
|
@@ -615,6 +628,10 @@ export declare class AgentcardClient {
|
|
|
615
628
|
private learnedSandbox;
|
|
616
629
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
617
630
|
private readonly inbox;
|
|
631
|
+
/** The chats (`threadId`, '' for a tool built without one) that need a kept message about the shopper alone no more: their history holds it, or their agent answered it (`forgetMessages`, `answered`). */
|
|
632
|
+
private readonly savedInChats;
|
|
633
|
+
/** The same marks by event id, for a copy of the message delivered later, such as a retried webhook's: `delivered` starts that copy with them. */
|
|
634
|
+
private readonly savedInChatsById;
|
|
618
635
|
private readonly deliveredIds;
|
|
619
636
|
private readonly trace;
|
|
620
637
|
constructor(options: AgentcardClientOptions);
|
|
@@ -659,22 +676,41 @@ export declare class AgentcardClient {
|
|
|
659
676
|
delivered(externalUserId: string, message: ShopperMessage): void;
|
|
660
677
|
/**
|
|
661
678
|
* Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
|
|
662
|
-
* its `text`, so the shopper never reads that text
|
|
663
|
-
*
|
|
679
|
+
* its `text`, so the shopper never reads that text in that chat (`threadId`, or the tool built
|
|
680
|
+
* without one). A message about one purchase belongs to one chat, so it leaves the client:
|
|
681
|
+
* `delivered` skips it from now on, and no later `buy.turn` hands it to the model as words the
|
|
682
|
+
* shopper read. A message about the shopper alone, such as a saved card, is answered for that chat
|
|
683
|
+
* only: the shopper's other chats still keep it. `buy.nextStep` calls it.
|
|
664
684
|
*/
|
|
665
|
-
answered(externalUserId: string, message: ShopperMessage): void;
|
|
685
|
+
answered(externalUserId: string, message: ShopperMessage, threadId?: string): void;
|
|
666
686
|
/**
|
|
667
687
|
* Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
|
|
668
|
-
* post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the
|
|
688
|
+
* post its text after all, `delivered` keeps it, and a later `buy.turn` in that chat hands it to the model as words the
|
|
689
|
+
* shopper read.
|
|
690
|
+
*/
|
|
691
|
+
unanswered(externalUserId: string, message: ShopperMessage, threadId?: string): void;
|
|
692
|
+
/**
|
|
693
|
+
* The messages kept for this shopper that this chat reads, oldest first: each one about a purchase
|
|
694
|
+
* opened in this chat or about the shopper alone, that reached their chat and that no history
|
|
695
|
+
* `turn.messages` read in this chat has held yet. The chat is `threadId`, or the tool built without
|
|
696
|
+
* one, which reads the purchases opened under no thread. `buy.turn` reads them when it opens.
|
|
697
|
+
*/
|
|
698
|
+
keptMessages(externalUserId: string, threadId?: string): ShopperMessage[];
|
|
699
|
+
/**
|
|
700
|
+
* Forgets these messages for this shopper in this chat (`threadId`, or the tool built without one): `turn.messages`
|
|
701
|
+
* calls it once the history it reads holds them. A message about one purchase leaves the client, since one chat reads
|
|
702
|
+
* it. A message about the shopper alone, such as a saved card, stays kept for the shopper's other chats until their
|
|
703
|
+
* own histories hold it.
|
|
669
704
|
*/
|
|
670
|
-
|
|
705
|
+
forgetMessages(externalUserId: string, messages: readonly ShopperMessage[], threadId?: string): void;
|
|
671
706
|
/**
|
|
672
|
-
*
|
|
673
|
-
*
|
|
707
|
+
* Marks a message about the shopper alone as one this chat needs no more, its history holding it or its agent having
|
|
708
|
+
* answered it, or takes the mark back. The kept copies with the same event id carry the mark too, and so does a copy
|
|
709
|
+
* delivered later (`delivered`), while the client still remembers the id.
|
|
674
710
|
*/
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
|
|
711
|
+
private markChat;
|
|
712
|
+
/** Removes these messages from the shopper's kept messages, for every chat. */
|
|
713
|
+
private dropKept;
|
|
678
714
|
/** A company names the shopper exactly once; a shopper's own token already names them. */
|
|
679
715
|
private checkShopper;
|
|
680
716
|
private call;
|
package/dist/client.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { isRecord } from './verify.js';
|
|
12
12
|
import { listenToAgentcard } from './listen.js';
|
|
13
|
+
import { messageInChat } from './shopper-message.js';
|
|
13
14
|
/** The handlers that ask Agentcard to stream a turn. */
|
|
14
|
-
const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability'];
|
|
15
|
+
const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability', 'onReplyWritten'];
|
|
15
16
|
const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
|
|
16
17
|
export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
|
|
17
18
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
@@ -463,7 +464,7 @@ async function eachEvent(res, onEvent) {
|
|
|
463
464
|
}
|
|
464
465
|
/**
|
|
465
466
|
* A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
|
|
466
|
-
* status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `cart` and `venue_availability` goes to its handler
|
|
467
|
+
* status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `reply_written`, `cart` and `venue_availability` goes to its handler
|
|
467
468
|
* on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
|
|
468
469
|
* ends before its envelope, once it named the conversation, reads as a turn past its budget
|
|
469
470
|
* (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
|
|
@@ -542,6 +543,9 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
542
543
|
else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
|
|
543
544
|
deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
|
|
544
545
|
}
|
|
546
|
+
else if (event === 'reply_written' && stream.onReplyWritten) {
|
|
547
|
+
deliver(() => stream.onReplyWritten({ conversationId: named }));
|
|
548
|
+
}
|
|
545
549
|
else if (event === 'ack' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onAck) {
|
|
546
550
|
deliver(() => stream.onAck({ conversationId: named, text: data.text }));
|
|
547
551
|
}
|
|
@@ -586,6 +590,10 @@ export class AgentcardClient {
|
|
|
586
590
|
learnedSandbox = null;
|
|
587
591
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
588
592
|
inbox = new Map();
|
|
593
|
+
/** The chats (`threadId`, '' for a tool built without one) that need a kept message about the shopper alone no more: their history holds it, or their agent answered it (`forgetMessages`, `answered`). */
|
|
594
|
+
savedInChats = new WeakMap();
|
|
595
|
+
/** The same marks by event id, for a copy of the message delivered later, such as a retried webhook's: `delivered` starts that copy with them. */
|
|
596
|
+
savedInChatsById = new Map();
|
|
589
597
|
deliveredIds = new Set();
|
|
590
598
|
trace;
|
|
591
599
|
constructor(options) {
|
|
@@ -730,6 +738,10 @@ export class AgentcardClient {
|
|
|
730
738
|
this.deliveredIds.delete(this.deliveredIds.values().next().value);
|
|
731
739
|
}
|
|
732
740
|
const kept = this.inbox.get(externalUserId) ?? [];
|
|
741
|
+
// A copy delivered again, such as a retried webhook's, keeps the chats that already answered it.
|
|
742
|
+
const marks = str(message.eventId) ? this.savedInChatsById.get(message.eventId) : undefined;
|
|
743
|
+
if (marks)
|
|
744
|
+
this.savedInChats.set(message, new Set(marks));
|
|
733
745
|
// The shopper written to last moves to the end, so the one dropped first is the one written to longest ago.
|
|
734
746
|
this.inbox.delete(externalUserId);
|
|
735
747
|
this.inbox.set(externalUserId, [...kept, message].slice(-DELIVERED_PER_SHOPPER));
|
|
@@ -738,14 +750,21 @@ export class AgentcardClient {
|
|
|
738
750
|
}
|
|
739
751
|
/**
|
|
740
752
|
* Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
|
|
741
|
-
* its `text`, so the shopper never reads that text
|
|
742
|
-
*
|
|
753
|
+
* its `text`, so the shopper never reads that text in that chat (`threadId`, or the tool built
|
|
754
|
+
* without one). A message about one purchase belongs to one chat, so it leaves the client:
|
|
755
|
+
* `delivered` skips it from now on, and no later `buy.turn` hands it to the model as words the
|
|
756
|
+
* shopper read. A message about the shopper alone, such as a saved card, is answered for that chat
|
|
757
|
+
* only: the shopper's other chats still keep it. `buy.nextStep` calls it.
|
|
743
758
|
*/
|
|
744
|
-
answered(externalUserId, message) {
|
|
759
|
+
answered(externalUserId, message, threadId) {
|
|
745
760
|
if (!str(externalUserId))
|
|
746
761
|
throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
|
|
747
762
|
if (!isRecord(message))
|
|
748
763
|
throw new TypeError('Pass the message onMessage got.');
|
|
764
|
+
if (!message.threadId && !message.conversationId) {
|
|
765
|
+
this.markChat(externalUserId, message, threadId ?? '', true);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
749
768
|
if (str(message.eventId)) {
|
|
750
769
|
this.deliveredIds.add(message.eventId);
|
|
751
770
|
if (this.deliveredIds.size > DELIVERED_IDS_KEPT)
|
|
@@ -753,31 +772,82 @@ export class AgentcardClient {
|
|
|
753
772
|
}
|
|
754
773
|
const kept = this.inbox.get(externalUserId);
|
|
755
774
|
if (kept)
|
|
756
|
-
this.
|
|
775
|
+
this.dropKept(externalUserId, kept.filter((m) => m === message || (!!message.eventId && m.eventId === message.eventId)));
|
|
757
776
|
}
|
|
758
777
|
/**
|
|
759
778
|
* Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
|
|
760
|
-
* post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the
|
|
779
|
+
* post its text after all, `delivered` keeps it, and a later `buy.turn` in that chat hands it to the model as words the
|
|
780
|
+
* shopper read.
|
|
761
781
|
*/
|
|
762
|
-
unanswered(externalUserId, message) {
|
|
782
|
+
unanswered(externalUserId, message, threadId) {
|
|
763
783
|
if (!str(externalUserId))
|
|
764
784
|
throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
|
|
765
785
|
if (!isRecord(message))
|
|
766
786
|
throw new TypeError('Pass the message onMessage got.');
|
|
787
|
+
if (!message.threadId && !message.conversationId) {
|
|
788
|
+
this.markChat(externalUserId, message, threadId ?? '', false);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
767
791
|
if (str(message.eventId))
|
|
768
792
|
this.deliveredIds.delete(message.eventId);
|
|
769
793
|
}
|
|
770
794
|
/**
|
|
771
|
-
* The messages kept for this shopper, oldest first: each one
|
|
772
|
-
*
|
|
795
|
+
* The messages kept for this shopper that this chat reads, oldest first: each one about a purchase
|
|
796
|
+
* opened in this chat or about the shopper alone, that reached their chat and that no history
|
|
797
|
+
* `turn.messages` read in this chat has held yet. The chat is `threadId`, or the tool built without
|
|
798
|
+
* one, which reads the purchases opened under no thread. `buy.turn` reads them when it opens.
|
|
799
|
+
*/
|
|
800
|
+
keptMessages(externalUserId, threadId) {
|
|
801
|
+
const chat = threadId ?? '';
|
|
802
|
+
return (this.inbox.get(externalUserId) ?? []).filter((m) => messageInChat(m, threadId) && !this.savedInChats.get(m)?.has(chat));
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Forgets these messages for this shopper in this chat (`threadId`, or the tool built without one): `turn.messages`
|
|
806
|
+
* calls it once the history it reads holds them. A message about one purchase leaves the client, since one chat reads
|
|
807
|
+
* it. A message about the shopper alone, such as a saved card, stays kept for the shopper's other chats until their
|
|
808
|
+
* own histories hold it.
|
|
773
809
|
*/
|
|
774
|
-
|
|
775
|
-
|
|
810
|
+
forgetMessages(externalUserId, messages, threadId) {
|
|
811
|
+
const chat = threadId ?? '';
|
|
812
|
+
this.dropKept(externalUserId, messages.filter((m) => {
|
|
813
|
+
if (m.threadId || m.conversationId)
|
|
814
|
+
return true;
|
|
815
|
+
this.markChat(externalUserId, m, chat, true);
|
|
816
|
+
return false;
|
|
817
|
+
}));
|
|
776
818
|
}
|
|
777
|
-
/**
|
|
778
|
-
|
|
819
|
+
/**
|
|
820
|
+
* Marks a message about the shopper alone as one this chat needs no more, its history holding it or its agent having
|
|
821
|
+
* answered it, or takes the mark back. The kept copies with the same event id carry the mark too, and so does a copy
|
|
822
|
+
* delivered later (`delivered`), while the client still remembers the id.
|
|
823
|
+
*/
|
|
824
|
+
markChat(externalUserId, message, chat, done) {
|
|
825
|
+
const id = str(message.eventId);
|
|
826
|
+
const mark = (chats) => {
|
|
827
|
+
if (done)
|
|
828
|
+
chats.add(chat);
|
|
829
|
+
else
|
|
830
|
+
chats.delete(chat);
|
|
831
|
+
return chats;
|
|
832
|
+
};
|
|
833
|
+
const same = (m) => m === message || (!!id && m.eventId === id);
|
|
834
|
+
for (const m of new Set([message, ...(this.inbox.get(externalUserId) ?? []).filter(same)])) {
|
|
835
|
+
this.savedInChats.set(m, mark(this.savedInChats.get(m) ?? new Set()));
|
|
836
|
+
}
|
|
837
|
+
if (!id)
|
|
838
|
+
return;
|
|
839
|
+
// The id marked last moves to the end, so the one dropped first is the one marked longest ago. A kept copy keeps its
|
|
840
|
+
// own marks after its id is dropped.
|
|
841
|
+
const chats = mark(this.savedInChatsById.get(id) ?? new Set());
|
|
842
|
+
this.savedInChatsById.delete(id);
|
|
843
|
+
this.savedInChatsById.set(id, chats);
|
|
844
|
+
if (this.savedInChatsById.size > DELIVERED_IDS_KEPT)
|
|
845
|
+
this.savedInChatsById.delete(this.savedInChatsById.keys().next().value);
|
|
846
|
+
}
|
|
847
|
+
/** Removes these messages from the shopper's kept messages, for every chat. */
|
|
848
|
+
dropKept(externalUserId, messages) {
|
|
779
849
|
const kept = this.inbox.get(externalUserId);
|
|
780
|
-
if (!kept)
|
|
850
|
+
if (!kept || messages.length === 0)
|
|
781
851
|
return;
|
|
782
852
|
const left = kept.filter((m) => !messages.includes(m));
|
|
783
853
|
if (left.length > 0)
|
|
@@ -72,6 +72,12 @@ export interface ShopperMessage {
|
|
|
72
72
|
externalUserId: string | null;
|
|
73
73
|
/** The buy conversation the event belongs to, when Agentcard names one. */
|
|
74
74
|
conversationId: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Your own id for the chat or thread that conversation opened under (the buy tool's `threadId`, POST /buy `thread_id`):
|
|
77
|
+
* the chat to post `text` into. Null for a purchase that opened under none, and for a message about the shopper alone,
|
|
78
|
+
* such as a saved card.
|
|
79
|
+
*/
|
|
80
|
+
threadId: string | null;
|
|
75
81
|
/** Ready to send to the shopper as is. */
|
|
76
82
|
text: string;
|
|
77
83
|
/** For your agent: the facts of what happened, marked as written by the system and not typed by the shopper. */
|
|
@@ -97,6 +103,12 @@ export interface ShopperMessage {
|
|
|
97
103
|
/** Unix seconds. */
|
|
98
104
|
created: number;
|
|
99
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether a chat reads this message: one about a purchase opened under the chat's thread, matched both ways, so the chat
|
|
108
|
+
* of a tool built without `threadId` reads the purchases opened under none and never a chat's, or one about the shopper
|
|
109
|
+
* alone, which names neither a thread nor a conversation. Internal: `buyTool` and the client's kept messages share it.
|
|
110
|
+
*/
|
|
111
|
+
export declare function messageInChat(message: Pick<ShopperMessage, 'threadId' | 'conversationId'>, threadId?: string): boolean;
|
|
100
112
|
/**
|
|
101
113
|
* The message an event carries for a shopper, or null when it carries none: an event type that is
|
|
102
114
|
* not a shopper event, an order event that names no shopper, or a delivery stage the package does
|
package/dist/shopper-message.js
CHANGED
|
@@ -89,6 +89,7 @@ function orderBase(event, type) {
|
|
|
89
89
|
userId,
|
|
90
90
|
externalUserId: id(data.external_user_id) ?? null,
|
|
91
91
|
conversationId: id(data.conversation_id) ?? null,
|
|
92
|
+
threadId: id(data.thread_id) ?? null,
|
|
92
93
|
clientId: id(connection.client_id) ?? null,
|
|
93
94
|
livemode: event.livemode,
|
|
94
95
|
created: event.created,
|
|
@@ -180,6 +181,14 @@ function readOrderUpdated(event) {
|
|
|
180
181
|
order,
|
|
181
182
|
};
|
|
182
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Whether a chat reads this message: one about a purchase opened under the chat's thread, matched both ways, so the chat
|
|
186
|
+
* of a tool built without `threadId` reads the purchases opened under none and never a chat's, or one about the shopper
|
|
187
|
+
* alone, which names neither a thread nor a conversation. Internal: `buyTool` and the client's kept messages share it.
|
|
188
|
+
*/
|
|
189
|
+
export function messageInChat(message, threadId) {
|
|
190
|
+
return (message.threadId || undefined) === (threadId || undefined) || (!message.threadId && !message.conversationId);
|
|
191
|
+
}
|
|
183
192
|
/**
|
|
184
193
|
* The message an event carries for a shopper, or null when it carries none: an event type that is
|
|
185
194
|
* not a shopper event, an order event that names no shopper, or a delivery stage the package does
|
|
@@ -198,6 +207,7 @@ export function readShopperMessage(event) {
|
|
|
198
207
|
...message,
|
|
199
208
|
type: 'purchase.message',
|
|
200
209
|
conversationId: id(event.data.conversation_id) ?? null,
|
|
210
|
+
threadId: id(event.data.thread_id) ?? null,
|
|
201
211
|
advances: NEXT_STEP.has(message.kind),
|
|
202
212
|
order: purchaseOrder(message.kind, message.facts, event.created),
|
|
203
213
|
};
|
package/dist/turn-record.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
* Whether a turn keeps its record and a dropped stream reads it back.
|
|
8
|
-
*
|
|
7
|
+
* Whether a turn keeps its record and a dropped stream reads it back. When it is off, `turnStream` streams without
|
|
8
|
+
* saving and `streamTurn` reports a drop as the error it was; `recover: false` does the same for the one call it is passed to.
|
|
9
9
|
*/
|
|
10
10
|
export declare const TURN_RECOVERY_ON: boolean;
|
|
11
11
|
/** One event of a turn, as its server-sent frame carries it: `event: <event>` and `data: <JSON of data>`. */
|
package/dist/turn-record.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
* Whether a turn keeps its record and a dropped stream reads it back.
|
|
8
|
-
*
|
|
7
|
+
* Whether a turn keeps its record and a dropped stream reads it back. When it is off, `turnStream` streams without
|
|
8
|
+
* saving and `streamTurn` reports a drop as the error it was; `recover: false` does the same for the one call it is passed to.
|
|
9
9
|
*/
|
|
10
|
-
export const TURN_RECOVERY_ON =
|
|
10
|
+
export const TURN_RECOVERY_ON = true;
|
|
11
11
|
/** A turn id the browser makes up front: 8 to 64 letters, digits, `-` or `_`. Check one before using it in a key. */
|
|
12
12
|
export function isTurnId(value) {
|
|
13
13
|
return typeof value === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(value);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/commerce-sdk",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.5",
|
|
4
4
|
"description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
|
|
5
5
|
"homepage": "https://opencommerce.sh",
|
|
6
6
|
"type": "module",
|