@agent-cards/commerce-sdk 0.11.0 → 0.14.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 +167 -10
- package/dist/ack.d.ts +36 -0
- package/dist/ack.js +36 -8
- package/dist/action-links.d.ts +64 -0
- package/dist/action-links.js +77 -0
- package/dist/browser.d.ts +106 -0
- package/dist/browser.js +118 -0
- package/dist/buy-tool.d.ts +218 -26
- package/dist/buy-tool.js +735 -43
- package/dist/client.d.ts +155 -4
- package/dist/client.js +225 -24
- package/dist/definition.js +25 -2
- package/dist/direct.d.ts +50 -0
- package/dist/direct.js +237 -0
- package/dist/events.d.ts +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -4
- package/dist/message.d.ts +4 -1
- package/dist/messages.d.ts +9 -9
- package/dist/messages.js +6 -6
- package/dist/model-view.d.ts +66 -0
- package/dist/model-view.js +207 -5
- package/dist/shopper-message.d.ts +16 -1
- package/dist/shopper-message.js +11 -0
- package/package.json +13 -1
package/dist/client.d.ts
CHANGED
|
@@ -44,20 +44,118 @@ export interface BuyCatalogUpdate {
|
|
|
44
44
|
catalog: BuyCatalog;
|
|
45
45
|
/** When the search ran, as the answer's `catalog.as_of` names it: the same search in the answer has the same value. */
|
|
46
46
|
asOf: string | null;
|
|
47
|
+
/** How many of the products found are in `catalog.items`, the cards to show. Null when Agentcard sent no count. */
|
|
48
|
+
shown: number | null;
|
|
49
|
+
/** How many distinct products the turn's searches found so far. Null when Agentcard sent no count. */
|
|
50
|
+
found: number | null;
|
|
51
|
+
/** The products found past the cards, in the order they were found, to show on "show more" with no new search. */
|
|
52
|
+
more: BuyCatalog['items'];
|
|
47
53
|
}
|
|
48
|
-
/**
|
|
54
|
+
/** How many products a running turn found so far, and how many of them it shows: "8 of 23 shown". */
|
|
55
|
+
export interface BuyFoundUpdate {
|
|
56
|
+
conversationId: string;
|
|
57
|
+
shown: number;
|
|
58
|
+
found: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* What a running turn is doing, the moment a step starts: `text` is a short line to show in place of loading dots, such as
|
|
62
|
+
* `searching Target and Walmart` or `adding it to your cart`, and `step` names the step for an app that writes its own,
|
|
63
|
+
* such as `searching`, `adding_to_cart` or `store_checkout`. No status comes for a turn that only writes its reply.
|
|
64
|
+
*/
|
|
65
|
+
export interface BuyTurnStatus {
|
|
66
|
+
conversationId: string | null;
|
|
67
|
+
step: string;
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
/** One message Agentcard finished writing while the turn runs, links included, as the answer's `messages` then holds it. */
|
|
71
|
+
export interface BuySegmentUpdate {
|
|
72
|
+
conversationId: string | null;
|
|
73
|
+
text: string;
|
|
74
|
+
}
|
|
75
|
+
/** The carts on the table the moment a running turn builds or changes one, before the message that announces it. */
|
|
76
|
+
export interface BuyCartUpdate {
|
|
77
|
+
conversationId: string;
|
|
78
|
+
/** The cart a confirm would place, as the answer's `cart` names it, or null when none would be. */
|
|
79
|
+
cart: BuyCart | null;
|
|
80
|
+
/** Every cart on the table, oldest first, as the answer's `carts`. */
|
|
81
|
+
carts: BuyCart[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A piece of a message while Agentcard writes it. A piece can hold words Agentcard drops before the message is done, such
|
|
85
|
+
* as a line that only says it is looking, so show the pieces as a draft and replace them with each `onSegment` message.
|
|
86
|
+
*/
|
|
87
|
+
export interface BuyDeltaUpdate {
|
|
88
|
+
conversationId: string | null;
|
|
89
|
+
text: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Called while a purchase turn runs. Passing any one asks Agentcard to stream the turn. `onCatalog`, `onSegment`,
|
|
93
|
+
* `onCart` and `onDelta` hear their events one at a time, in the order Agentcard sent them, each once the one before it
|
|
94
|
+
* settled, so a piece of the next message never reaches the app before the message ahead of it. The client waits for
|
|
95
|
+
* the last of them before it hands back the turn's answer.
|
|
96
|
+
*/
|
|
49
97
|
export interface BuyStreamHandlers {
|
|
50
98
|
onProgress?: (progress: BuyProgress) => void;
|
|
51
|
-
/**
|
|
99
|
+
/** Each step the turn starts, as a line to show while the shopper waits. */
|
|
100
|
+
onStatus?: (status: BuyTurnStatus) => void;
|
|
101
|
+
/** How many products the turn found, each time the count grows. */
|
|
102
|
+
onFound?: (update: BuyFoundUpdate) => void | Promise<void>;
|
|
52
103
|
onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
|
|
104
|
+
/** Each message Agentcard writes, the moment it is written. */
|
|
105
|
+
onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
|
|
106
|
+
/** The carts, each time the turn builds or changes one. */
|
|
107
|
+
onCart?: (update: BuyCartUpdate) => void | Promise<void>;
|
|
108
|
+
/** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
|
|
109
|
+
onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
|
|
53
110
|
}
|
|
54
111
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
55
112
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
56
113
|
export declare const BUY_TURN_TIMEOUT_MS = 130000;
|
|
114
|
+
/** The W3C trace context a `POST /buy` carries, so the backend's spans join the caller's trace. */
|
|
115
|
+
export interface TraceContext {
|
|
116
|
+
traceparent: string;
|
|
117
|
+
tracestate?: string;
|
|
118
|
+
}
|
|
119
|
+
/** The part of `@opentelemetry/api` the client calls: pass the module itself as `otel`. */
|
|
120
|
+
export interface OtelApi {
|
|
121
|
+
trace: {
|
|
122
|
+
getTracer(name: string): OtelTracer;
|
|
123
|
+
};
|
|
124
|
+
context: {
|
|
125
|
+
active(): unknown;
|
|
126
|
+
};
|
|
127
|
+
propagation: {
|
|
128
|
+
inject(context: unknown, carrier: Record<string, string>): void;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
interface OtelTracer {
|
|
132
|
+
startActiveSpan<T>(name: string, options: {
|
|
133
|
+
kind?: number;
|
|
134
|
+
attributes?: Record<string, string | boolean>;
|
|
135
|
+
}, fn: (span: OtelSpan) => T): T;
|
|
136
|
+
}
|
|
137
|
+
interface OtelSpan {
|
|
138
|
+
setAttribute(key: string, value: string | number | boolean): unknown;
|
|
139
|
+
setStatus(status: {
|
|
140
|
+
code: number;
|
|
141
|
+
message?: string;
|
|
142
|
+
}): unknown;
|
|
143
|
+
end(): void;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* How the client finds the trace a `POST /buy` belongs to. `otel` is `@opentelemetry/api`: by default the client
|
|
147
|
+
* loads it when the app installed it, and `false` turns that off. `traceContext` names the trace by hand, for an app
|
|
148
|
+
* without OpenTelemetry: return its `traceparent`, or nothing to send none. The active OpenTelemetry context wins.
|
|
149
|
+
*/
|
|
150
|
+
export interface AgentcardTraceOptions {
|
|
151
|
+
otel?: OtelApi | false;
|
|
152
|
+
traceContext?: () => TraceContext | string | null | undefined;
|
|
153
|
+
}
|
|
57
154
|
/** A company's client credentials, or one shopper's own access token. */
|
|
58
155
|
export type AgentcardClientOptions = {
|
|
59
156
|
baseUrl?: string;
|
|
60
157
|
fetch?: FetchLike;
|
|
158
|
+
trace?: AgentcardTraceOptions;
|
|
61
159
|
} & ({
|
|
62
160
|
clientId: string;
|
|
63
161
|
clientSecret: string;
|
|
@@ -91,6 +189,8 @@ export interface BuyRequest {
|
|
|
91
189
|
confirm?: string | true;
|
|
92
190
|
/** Continue the shopper's newest open conversation instead of starting one. */
|
|
93
191
|
resume?: true;
|
|
192
|
+
/** The conversation a confirm of a cart hash belongs to, in place of `resume`. */
|
|
193
|
+
conversation_id?: string;
|
|
94
194
|
/** Drop the cart on the table and start a new order. */
|
|
95
195
|
new_order?: true;
|
|
96
196
|
/**
|
|
@@ -117,8 +217,23 @@ export interface BuyStore {
|
|
|
117
217
|
export interface BuyCartLine {
|
|
118
218
|
name: string;
|
|
119
219
|
qty: number;
|
|
220
|
+
/** One unit's price, as the store lists it. */
|
|
120
221
|
priceCents?: number;
|
|
222
|
+
/**
|
|
223
|
+
* What the line costs: its price with every add-on, times its quantity, before the store's own fees.
|
|
224
|
+
* The lines' totals add up to the cart's `subtotalCents`. Null when its price is unknown.
|
|
225
|
+
*/
|
|
226
|
+
lineTotalCents: number | null;
|
|
227
|
+
/** The choices made on the line, by the store's names ("Carnitas", "Extra Guacamole"). Empty when none. */
|
|
228
|
+
options: string[];
|
|
229
|
+
/** The variant the line is, by the store's name ("Birthday (Medium)"), or null when the store names none. */
|
|
230
|
+
variant: string | null;
|
|
121
231
|
product_id: string | null;
|
|
232
|
+
/**
|
|
233
|
+
* The id of the product card this line is, as `BuyProduct.ref` names it: a shown product whose `ref`
|
|
234
|
+
* (else `id`) equals it is in the cart. Null from an older API.
|
|
235
|
+
*/
|
|
236
|
+
ref: string | null;
|
|
122
237
|
/** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
|
|
123
238
|
url: string | null;
|
|
124
239
|
}
|
|
@@ -138,7 +253,8 @@ export interface BuyCart {
|
|
|
138
253
|
items: BuyCartLine[];
|
|
139
254
|
/** The items alone, or null from an older API. */
|
|
140
255
|
subtotalCents: number | null;
|
|
141
|
-
serviceFeesCents: number;
|
|
256
|
+
serviceFeesCents: number | null;
|
|
257
|
+
serviceFeesStatus?: 'known' | 'pending' | 'unknown';
|
|
142
258
|
/** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
|
|
143
259
|
merchantFeeLines: BuyCartFeeLine[] | null;
|
|
144
260
|
tipCents: number;
|
|
@@ -172,9 +288,16 @@ export interface BuyCatalogItem {
|
|
|
172
288
|
ref: string | null;
|
|
173
289
|
name: string | null;
|
|
174
290
|
priceCents: number | null;
|
|
291
|
+
/** True when `priceCents` is a "from" price: the lowest price of a product priced by a required choice, such as a burger's patty count or a drink's size. */
|
|
292
|
+
price_from?: true;
|
|
175
293
|
/** The ISO 4217 code the store prices the product in, or null when Agentcard sent none. */
|
|
176
294
|
currency: string | null;
|
|
177
295
|
image_url: string | null;
|
|
296
|
+
/**
|
|
297
|
+
* Every photo of the product Agentcard sent, `image_url` first, each once, at most eight. One entry when the
|
|
298
|
+
* product has one photo, and none when it has none. An older API sends one photo at most.
|
|
299
|
+
*/
|
|
300
|
+
image_urls: string[];
|
|
178
301
|
store: BuyStore | null;
|
|
179
302
|
/** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
|
|
180
303
|
url: string | null;
|
|
@@ -209,7 +332,7 @@ export interface BuyStoreChoice {
|
|
|
209
332
|
eta_minutes: number | null;
|
|
210
333
|
image_url: string | null;
|
|
211
334
|
}
|
|
212
|
-
/** The stores an answer's reply presents to choose from, at most
|
|
335
|
+
/** The stores an answer's reply presents to choose from, at most eight, in the order the reply names them. */
|
|
213
336
|
export interface BuyStores {
|
|
214
337
|
merchant: string;
|
|
215
338
|
merchant_name: string;
|
|
@@ -236,6 +359,15 @@ export interface BuyPaymentSource {
|
|
|
236
359
|
* not know reads as null.
|
|
237
360
|
*/
|
|
238
361
|
export type BuyNextAction = 'shipping_details' | 'guest_details' | 'store_login' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
|
|
362
|
+
/**
|
|
363
|
+
* A cart the store's checkout is still building after an answer went out, at `store`, since `started_at`: its cart and
|
|
364
|
+
* total reach the shopper as a message of their own (purchase.message `cart_ready`), and only the shopper's yes to that
|
|
365
|
+
* total confirms it.
|
|
366
|
+
*/
|
|
367
|
+
export interface BuyCartBuilding {
|
|
368
|
+
store: string;
|
|
369
|
+
started_at: string;
|
|
370
|
+
}
|
|
239
371
|
/** One turn's answer, every field read for its type; a field an older API does not send reads as null. */
|
|
240
372
|
export interface BuyTurn {
|
|
241
373
|
conversation_id: string;
|
|
@@ -273,6 +405,8 @@ export interface BuyTurn {
|
|
|
273
405
|
stores: BuyStores | null;
|
|
274
406
|
/** The store this turn's `pick.store_id` named, or an answer such as `the second one` picked; null without one. */
|
|
275
407
|
store_pick: BuyStorePick | null;
|
|
408
|
+
/** The cart the store's checkout is still building after this answer; null when none is, and on an older API. */
|
|
409
|
+
cart_building: BuyCartBuilding | null;
|
|
276
410
|
/** The answer as Agentcard sent it. */
|
|
277
411
|
raw: Record<string, unknown>;
|
|
278
412
|
}
|
|
@@ -307,6 +441,8 @@ export interface BuyConversation {
|
|
|
307
441
|
conversation_id: string;
|
|
308
442
|
status: 'open' | 'closed';
|
|
309
443
|
turn_in_progress: boolean;
|
|
444
|
+
/** The cart the store's checkout is still building on this conversation; null when none is, and on an older API. */
|
|
445
|
+
cart_building: BuyCartBuilding | null;
|
|
310
446
|
last_checkout: BuyLastCheckout | null;
|
|
311
447
|
orders: BuyOrder[];
|
|
312
448
|
carts: BuyCart[];
|
|
@@ -342,6 +478,8 @@ export declare class AgentcardApiError extends Error {
|
|
|
342
478
|
}
|
|
343
479
|
/** A turn as Agentcard answers it. */
|
|
344
480
|
export declare function turnOf(body: Record<string, unknown>): BuyTurn;
|
|
481
|
+
/** A cart being built, as an answer, a refusal or the conversation read names it. */
|
|
482
|
+
export declare function cartBuildingOf(value: unknown): BuyCartBuilding | null;
|
|
345
483
|
/** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
|
|
346
484
|
export declare class AgentcardClient {
|
|
347
485
|
readonly baseUrl: string;
|
|
@@ -354,6 +492,7 @@ export declare class AgentcardClient {
|
|
|
354
492
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
355
493
|
private readonly inbox;
|
|
356
494
|
private readonly deliveredIds;
|
|
495
|
+
private readonly trace;
|
|
357
496
|
constructor(options: AgentcardClientOptions);
|
|
358
497
|
/** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
|
|
359
498
|
get company(): boolean;
|
|
@@ -394,6 +533,17 @@ export declare class AgentcardClient {
|
|
|
394
533
|
* shopper: the listener and the buy tool share one client in one process.
|
|
395
534
|
*/
|
|
396
535
|
delivered(externalUserId: string, message: ShopperMessage): void;
|
|
536
|
+
/**
|
|
537
|
+
* Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
|
|
538
|
+
* its `text`, so the shopper never reads that text: `delivered` skips it from now on, and no later
|
|
539
|
+
* `buy.turn` hands it to the model as words the shopper read. `buy.nextStep` calls it.
|
|
540
|
+
*/
|
|
541
|
+
answered(externalUserId: string, message: ShopperMessage): void;
|
|
542
|
+
/**
|
|
543
|
+
* Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
|
|
544
|
+
* post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the shopper read.
|
|
545
|
+
*/
|
|
546
|
+
unanswered(externalUserId: string, message: ShopperMessage): void;
|
|
397
547
|
/**
|
|
398
548
|
* The messages kept for this shopper, oldest first: each one that reached their chat and that no
|
|
399
549
|
* history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
|
|
@@ -409,3 +559,4 @@ export declare class AgentcardClient {
|
|
|
409
559
|
private renewAfter;
|
|
410
560
|
private exchange;
|
|
411
561
|
}
|
|
562
|
+
export {};
|
package/dist/client.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { isRecord } from './verify.js';
|
|
12
12
|
import { listenToAgentcard } from './listen.js';
|
|
13
|
+
/** The handlers that ask Agentcard to stream a turn. */
|
|
14
|
+
const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onCart', 'onDelta'];
|
|
13
15
|
const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
|
|
14
16
|
export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
|
|
15
17
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
@@ -24,6 +26,37 @@ const DELIVERED_PER_SHOPPER = 20;
|
|
|
24
26
|
const DELIVERED_SHOPPERS = 10_000;
|
|
25
27
|
/** The event ids the client remembers taking in, so a message delivered twice reaches the model once. */
|
|
26
28
|
const DELIVERED_IDS_KEPT = 10_000;
|
|
29
|
+
/** `@opentelemetry/api`, loaded once, only when the app installed it: the package depends on nothing. */
|
|
30
|
+
const OTEL_API = '@opentelemetry/api';
|
|
31
|
+
let otelLoaded = null;
|
|
32
|
+
function loadOtel() {
|
|
33
|
+
otelLoaded ??= import(/* webpackIgnore: true */ /* @vite-ignore */ OTEL_API).then((m) => (m.trace && m.propagation && m.context ? m : m.default?.trace ? m.default : null), () => null);
|
|
34
|
+
return otelLoaded;
|
|
35
|
+
}
|
|
36
|
+
/** The `traceparent` and `tracestate` headers for one call: the active span's, else the app's own, else none. */
|
|
37
|
+
function traceHeaders(otel, own) {
|
|
38
|
+
const carrier = {};
|
|
39
|
+
if (otel) {
|
|
40
|
+
try {
|
|
41
|
+
otel.propagation.inject(otel.context.active(), carrier);
|
|
42
|
+
}
|
|
43
|
+
catch { /* A broken propagator never stops a purchase. */ }
|
|
44
|
+
if (str(carrier.traceparent))
|
|
45
|
+
return pickTrace(carrier);
|
|
46
|
+
}
|
|
47
|
+
let given;
|
|
48
|
+
try {
|
|
49
|
+
given = own?.();
|
|
50
|
+
}
|
|
51
|
+
catch { /* An app's own trace lookup that throws sends no trace. */ }
|
|
52
|
+
const ctx = typeof given === 'string' ? { traceparent: given } : given;
|
|
53
|
+
// Only a well-formed W3C traceparent goes out: anything else would break the header or the backend's parse.
|
|
54
|
+
return isRecord(ctx) && TRACEPARENT.test(String(ctx.traceparent)) ? pickTrace(ctx) : {};
|
|
55
|
+
}
|
|
56
|
+
const TRACEPARENT = /^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$/;
|
|
57
|
+
// A tracestate is printable ASCII of at most 512 characters (W3C); any other value is left off, so it can never stop a purchase.
|
|
58
|
+
const TRACESTATE = /^[\x20-\x7e]{1,512}$/;
|
|
59
|
+
const pickTrace = (c) => typeof c.tracestate === 'string' && TRACESTATE.test(c.tracestate) ? { traceparent: c.traceparent, tracestate: c.tracestate } : { traceparent: c.traceparent };
|
|
27
60
|
const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'store_login', 'add_card', 'confirm', 'verification_code', 'approve']);
|
|
28
61
|
/**
|
|
29
62
|
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
@@ -76,6 +109,30 @@ function pageUrl(v) {
|
|
|
76
109
|
return null;
|
|
77
110
|
}
|
|
78
111
|
}
|
|
112
|
+
/** The most photos one product carries. */
|
|
113
|
+
const IMAGES_MAX = 8;
|
|
114
|
+
/**
|
|
115
|
+
* A product's photos: `main` first, then each https address in `list`, in its order, each once, at most
|
|
116
|
+
* IMAGES_MAX. A value that is no https address, or that names a user before its host, is left out.
|
|
117
|
+
*/
|
|
118
|
+
function imagesOf(main, list) {
|
|
119
|
+
// The main photo passes the same check as the others: an https address without credentials. It stays as `imageUrl` spells
|
|
120
|
+
// it, so the list still starts with that address, and a later copy of it is skipped by its normalized form.
|
|
121
|
+
const mainPage = pageUrl(main);
|
|
122
|
+
const ok = !!main && !!mainPage && mainPage.startsWith('https:');
|
|
123
|
+
const out = ok ? [main] : [];
|
|
124
|
+
const seen = new Set(ok ? [mainPage] : []);
|
|
125
|
+
for (const v of Array.isArray(list) ? list : []) {
|
|
126
|
+
if (out.length >= IMAGES_MAX)
|
|
127
|
+
break;
|
|
128
|
+
const page = pageUrl(v);
|
|
129
|
+
if (page && page.startsWith('https:') && !seen.has(page)) {
|
|
130
|
+
seen.add(page);
|
|
131
|
+
out.push(page);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
79
136
|
function storeOf(v) {
|
|
80
137
|
if (!isRecord(v) || !str(v.id))
|
|
81
138
|
return null;
|
|
@@ -87,6 +144,13 @@ function feeLinesOf(v) {
|
|
|
87
144
|
const lines = v.filter(isRecord).flatMap((f) => (str(f.label) && num(f.amountCents) !== null ? [{ label: f.label, amountCents: f.amountCents }] : []));
|
|
88
145
|
return lines.length ? lines : null;
|
|
89
146
|
}
|
|
147
|
+
function feesOf(v) {
|
|
148
|
+
const amount = num(v.serviceFeesCents);
|
|
149
|
+
const status = v.merchantFeesPending === true || v.serviceFeesStatus === 'pending' ? 'pending'
|
|
150
|
+
: (v.serviceFeesStatus === undefined || v.serviceFeesStatus === 'known')
|
|
151
|
+
&& amount !== null && Number.isSafeInteger(amount) && amount >= 0 ? 'known' : 'unknown';
|
|
152
|
+
return { serviceFeesCents: status === 'known' ? amount : null, serviceFeesStatus: status };
|
|
153
|
+
}
|
|
90
154
|
function cartOf(v) {
|
|
91
155
|
if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
|
|
92
156
|
return null;
|
|
@@ -94,15 +158,24 @@ function cartOf(v) {
|
|
|
94
158
|
merchant: v.merchant,
|
|
95
159
|
merchant_name: str(v.merchant_name) ?? v.merchant,
|
|
96
160
|
store: storeOf(v.store),
|
|
97
|
-
items: Array.isArray(v.items) ? v.items.filter(isRecord).map((i) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
161
|
+
items: Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => {
|
|
162
|
+
const qty = num(i.qty) ?? 1;
|
|
163
|
+
const price = num(i.priceCents);
|
|
164
|
+
return {
|
|
165
|
+
name: str(i.name) ?? '',
|
|
166
|
+
qty,
|
|
167
|
+
...(price !== null ? { priceCents: price } : {}),
|
|
168
|
+
// An older API sends no line total: the line's price times its quantity is it.
|
|
169
|
+
lineTotalCents: num(i.lineTotalCents) ?? (price !== null ? price * qty : null),
|
|
170
|
+
options: Array.isArray(i.options) ? i.options.filter((o) => typeof o === 'string' && o.trim() !== '') : [],
|
|
171
|
+
variant: str(i.variant),
|
|
172
|
+
product_id: str(i.product_id),
|
|
173
|
+
ref: str(i.ref),
|
|
174
|
+
url: pageUrl(i.url),
|
|
175
|
+
};
|
|
176
|
+
}) : [],
|
|
104
177
|
subtotalCents: num(v.subtotalCents),
|
|
105
|
-
|
|
178
|
+
...feesOf(v),
|
|
106
179
|
merchantFeeLines: feeLinesOf(v.merchantFeeLines),
|
|
107
180
|
tipCents: num(v.tipCents) ?? 0,
|
|
108
181
|
totalCents: num(v.totalCents) ?? 0,
|
|
@@ -131,21 +204,31 @@ function flightOf(value) {
|
|
|
131
204
|
function catalogOf(v) {
|
|
132
205
|
if (!isRecord(v))
|
|
133
206
|
return null;
|
|
134
|
-
const items =
|
|
207
|
+
const items = catalogItemsOf(v.items);
|
|
208
|
+
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
|
|
209
|
+
}
|
|
210
|
+
/** A catalog block's products, as `items` or `more` lists them. */
|
|
211
|
+
function catalogItemsOf(v) {
|
|
212
|
+
return Array.isArray(v) ? v.filter(isRecord).map((i) => {
|
|
135
213
|
const flight = flightOf(i.flight);
|
|
136
214
|
return {
|
|
137
215
|
id: str(i.id) ?? '',
|
|
138
216
|
ref: str(i.ref),
|
|
139
217
|
name: str(i.name),
|
|
140
218
|
priceCents: num(i.priceCents),
|
|
219
|
+
...(i.price_from === true && num(i.priceCents) !== null ? { price_from: true } : {}),
|
|
141
220
|
currency: str(i.currency),
|
|
142
221
|
image_url: str(i.image_url),
|
|
222
|
+
image_urls: imagesOf(str(i.image_url), i.image_urls),
|
|
143
223
|
...(flight ? { flight } : {}),
|
|
144
224
|
store: storeOf(i.store),
|
|
145
225
|
url: pageUrl(i.url),
|
|
146
226
|
};
|
|
147
227
|
}) : [];
|
|
148
|
-
|
|
228
|
+
}
|
|
229
|
+
/** A count Agentcard sent: a whole number, never negative. Null otherwise. */
|
|
230
|
+
function countOf(v) {
|
|
231
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
|
|
149
232
|
}
|
|
150
233
|
/** The stores an answer presents: each one with an id and a name, and no block when none has both. */
|
|
151
234
|
function storesOf(v) {
|
|
@@ -194,15 +277,21 @@ export function turnOf(body) {
|
|
|
194
277
|
pick: isRecord(body.pick) && str(body.pick.product_id) ? { product_id: body.pick.product_id, ref: str(body.pick.ref), in_cart: body.pick.in_cart === true } : null,
|
|
195
278
|
stores: storesOf(body.stores),
|
|
196
279
|
store_pick: isRecord(body.store_pick) && str(body.store_pick.store_id) ? { store_id: body.store_pick.store_id, name: str(body.store_pick.name), selected: body.store_pick.selected === true } : null,
|
|
280
|
+
cart_building: cartBuildingOf(body.cart_building),
|
|
197
281
|
raw: body,
|
|
198
282
|
};
|
|
199
283
|
}
|
|
284
|
+
/** A cart being built, as an answer, a refusal or the conversation read names it. */
|
|
285
|
+
export function cartBuildingOf(value) {
|
|
286
|
+
return isRecord(value) && str(value.store) ? { store: value.store, started_at: str(value.started_at) ?? '' } : null;
|
|
287
|
+
}
|
|
200
288
|
function conversationOf(body) {
|
|
201
289
|
const last = isRecord(body.last_checkout) ? body.last_checkout : null;
|
|
202
290
|
return {
|
|
203
291
|
conversation_id: str(body.conversation_id) ?? '',
|
|
204
292
|
status: body.status === 'closed' ? 'closed' : 'open',
|
|
205
293
|
turn_in_progress: body.turn_in_progress === true,
|
|
294
|
+
cart_building: cartBuildingOf(body.cart_building),
|
|
206
295
|
last_checkout: last ? {
|
|
207
296
|
at: str(last.at) ?? '',
|
|
208
297
|
merchant: str(last.merchant) ?? '',
|
|
@@ -335,14 +424,21 @@ async function eachEvent(res, onEvent) {
|
|
|
335
424
|
}
|
|
336
425
|
/**
|
|
337
426
|
* A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
|
|
338
|
-
* status in `http_status`. Each `progress`
|
|
339
|
-
*
|
|
427
|
+
* status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment` and `cart` goes to its handler
|
|
428
|
+
* on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
|
|
429
|
+
* ends before its envelope, once it named the conversation, reads as a turn past its budget
|
|
340
430
|
* (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
|
|
341
431
|
*/
|
|
342
432
|
async function readStreamedTurn(res, stream, timeoutMs) {
|
|
343
433
|
let envelope = null;
|
|
344
434
|
let conversationId = null;
|
|
345
|
-
|
|
435
|
+
// The catalog, segment, cart and delta handlers run one after another, in the order their events arrived, so an app
|
|
436
|
+
// never shows a message above the cards it names, nor a piece of the next message before the message ahead of it. The
|
|
437
|
+
// stream is read on meanwhile, and the answer waits for the last one. A handler that throws costs only itself.
|
|
438
|
+
let delivering = Promise.resolve();
|
|
439
|
+
const deliver = (hand) => {
|
|
440
|
+
delivering = delivering.then(() => hand()).catch(() => { });
|
|
441
|
+
};
|
|
346
442
|
let failure = null;
|
|
347
443
|
try {
|
|
348
444
|
await eachEvent(res, (event, text) => {
|
|
@@ -350,6 +446,8 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
350
446
|
if (!data)
|
|
351
447
|
return;
|
|
352
448
|
conversationId = str(data.conversation_id) ?? conversationId;
|
|
449
|
+
// The conversation this event belongs to: its own, or the one an earlier event of the same turn named.
|
|
450
|
+
const named = conversationId;
|
|
353
451
|
if (event === 'envelope') {
|
|
354
452
|
envelope = data;
|
|
355
453
|
return true;
|
|
@@ -362,19 +460,59 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
362
460
|
// The app's display failing never fails the turn.
|
|
363
461
|
}
|
|
364
462
|
}
|
|
365
|
-
else if (event === '
|
|
463
|
+
else if (event === 'status' && typeof data.text === 'string' && data.text.trim() !== '' && typeof data.step === 'string' && stream.onStatus) {
|
|
464
|
+
try {
|
|
465
|
+
stream.onStatus({ conversationId: named, step: data.step, text: data.text });
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// The app's display failing never fails the turn.
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else if (event === 'delta' && typeof data.text === 'string' && data.text !== '' && stream.onDelta) {
|
|
472
|
+
deliver(() => stream.onDelta({ conversationId: named, text: data.text }));
|
|
473
|
+
}
|
|
474
|
+
else if (event === 'catalog' && (stream.onCatalog || stream.onFound) && str(data.conversation_id)) {
|
|
366
475
|
const catalog = catalogOf(data.catalog);
|
|
367
476
|
if (!catalog || catalog.items.length === 0)
|
|
368
477
|
return;
|
|
369
|
-
const
|
|
370
|
-
|
|
478
|
+
const block = data.catalog;
|
|
479
|
+
const asOf = str(block.as_of);
|
|
480
|
+
const shown = countOf(block.shown);
|
|
481
|
+
const found = countOf(block.found);
|
|
482
|
+
const update = { conversationId: data.conversation_id, catalog, asOf, shown, found, more: catalogItemsOf(block.more) };
|
|
483
|
+
// The count follows the cards it counts, so an app never shows "8 of 23" above no cards, and it settles before the
|
|
484
|
+
// next event, so a later count never lands before an earlier one. A card display that throws never costs the count.
|
|
485
|
+
deliver(async () => {
|
|
486
|
+
try {
|
|
487
|
+
await stream.onCatalog?.(update);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// The app's display failing never fails the turn.
|
|
491
|
+
}
|
|
492
|
+
if (shown !== null && found !== null && found >= shown)
|
|
493
|
+
await stream.onFound?.({ conversationId: update.conversationId, shown, found });
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
else if (event === 'find' && stream.onFound && str(data.conversation_id)) {
|
|
497
|
+
// Find mode's count from the product index: the cards it shows of the products it found.
|
|
498
|
+
const shown = countOf(data.shown);
|
|
499
|
+
const found = countOf(data.found);
|
|
500
|
+
if (shown !== null && found !== null && found >= shown)
|
|
501
|
+
deliver(() => stream.onFound({ conversationId: data.conversation_id, shown, found }));
|
|
502
|
+
}
|
|
503
|
+
else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
|
|
504
|
+
deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
|
|
505
|
+
}
|
|
506
|
+
else if (event === 'cart' && stream.onCart && str(data.conversation_id)) {
|
|
507
|
+
const update = { conversationId: data.conversation_id, cart: cartOf(data.cart), carts: cartsOf(data.carts) };
|
|
508
|
+
deliver(() => stream.onCart(update));
|
|
371
509
|
}
|
|
372
510
|
});
|
|
373
511
|
}
|
|
374
512
|
catch (error) {
|
|
375
513
|
failure = error;
|
|
376
514
|
}
|
|
377
|
-
await
|
|
515
|
+
await delivering;
|
|
378
516
|
const answered = envelope;
|
|
379
517
|
if (answered) {
|
|
380
518
|
const { http_status: httpStatus, ...body } = answered;
|
|
@@ -398,6 +536,7 @@ export class AgentcardClient {
|
|
|
398
536
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
399
537
|
inbox = new Map();
|
|
400
538
|
deliveredIds = new Set();
|
|
539
|
+
trace;
|
|
401
540
|
constructor(options) {
|
|
402
541
|
const o = (options ?? {});
|
|
403
542
|
const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
|
|
@@ -416,6 +555,7 @@ export class AgentcardClient {
|
|
|
416
555
|
this.baseUrl = (str(o.baseUrl) ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
417
556
|
this.credentials = hasCredentials ? { clientId: o.clientId, clientSecret: o.clientSecret } : null;
|
|
418
557
|
this.shopperToken = hasCredentials ? null : o.accessToken;
|
|
558
|
+
this.trace = isRecord(o.trace) ? o.trace : {};
|
|
419
559
|
}
|
|
420
560
|
/** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
|
|
421
561
|
get company() {
|
|
@@ -450,10 +590,39 @@ export class AgentcardClient {
|
|
|
450
590
|
if (!isRecord(body))
|
|
451
591
|
throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
|
|
452
592
|
this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
|
|
453
|
-
const streamed = !!stream && (
|
|
454
|
-
// A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
|
|
455
|
-
|
|
456
|
-
|
|
593
|
+
const streamed = !!stream && STREAM_HANDLERS.some((name) => typeof stream[name] === 'function');
|
|
594
|
+
// A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too. The pieces of
|
|
595
|
+
// each message go out only to a caller that asks for them.
|
|
596
|
+
const path = !streamed ? '/buy' : typeof stream.onDelta === 'function' ? '/buy?stream=deltas&soft_timeout=1' : '/buy?stream=1&soft_timeout=1';
|
|
597
|
+
const otel = this.trace.otel === false ? null : this.trace.otel ?? await loadOtel();
|
|
598
|
+
const run = async () => {
|
|
599
|
+
const headers = traceHeaders(otel, this.trace.traceContext);
|
|
600
|
+
return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined, headers));
|
|
601
|
+
};
|
|
602
|
+
if (!otel)
|
|
603
|
+
return run();
|
|
604
|
+
// One `sdk.buy` span per call, a client span (kind 2) the backend's `buy.request` hangs under. It names the
|
|
605
|
+
// conversation and how the call ended, never what the shopper said.
|
|
606
|
+
const attributes = { 'agentcard.streamed': streamed };
|
|
607
|
+
const conversationId = str(body.conversation_id);
|
|
608
|
+
if (conversationId)
|
|
609
|
+
attributes['agentcard.conversation_id'] = conversationId;
|
|
610
|
+
return otel.trace.getTracer('commerce-sdk').startActiveSpan('sdk.buy', { kind: 2, attributes }, async (span) => {
|
|
611
|
+
try {
|
|
612
|
+
const turn = await run();
|
|
613
|
+
if (turn.conversation_id)
|
|
614
|
+
span.setAttribute('agentcard.conversation_id', turn.conversation_id);
|
|
615
|
+
span.setAttribute('agentcard.buy_status', turn.status);
|
|
616
|
+
return turn;
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
span.setStatus({ code: 2, message: error instanceof AgentcardApiError ? error.code : 'error' });
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
span.end();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
457
626
|
}
|
|
458
627
|
/** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
|
|
459
628
|
async conversation(conversationId, who = {}) {
|
|
@@ -516,6 +685,37 @@ export class AgentcardClient {
|
|
|
516
685
|
if (this.inbox.size > DELIVERED_SHOPPERS)
|
|
517
686
|
this.inbox.delete(this.inbox.keys().next().value);
|
|
518
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
|
|
690
|
+
* its `text`, so the shopper never reads that text: `delivered` skips it from now on, and no later
|
|
691
|
+
* `buy.turn` hands it to the model as words the shopper read. `buy.nextStep` calls it.
|
|
692
|
+
*/
|
|
693
|
+
answered(externalUserId, message) {
|
|
694
|
+
if (!str(externalUserId))
|
|
695
|
+
throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
|
|
696
|
+
if (!isRecord(message))
|
|
697
|
+
throw new TypeError('Pass the message onMessage got.');
|
|
698
|
+
if (str(message.eventId)) {
|
|
699
|
+
this.deliveredIds.add(message.eventId);
|
|
700
|
+
if (this.deliveredIds.size > DELIVERED_IDS_KEPT)
|
|
701
|
+
this.deliveredIds.delete(this.deliveredIds.values().next().value);
|
|
702
|
+
}
|
|
703
|
+
const kept = this.inbox.get(externalUserId);
|
|
704
|
+
if (kept)
|
|
705
|
+
this.forgetMessages(externalUserId, kept.filter((m) => m === message || (!!message.eventId && m.eventId === message.eventId)));
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
|
|
709
|
+
* post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the shopper read.
|
|
710
|
+
*/
|
|
711
|
+
unanswered(externalUserId, message) {
|
|
712
|
+
if (!str(externalUserId))
|
|
713
|
+
throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
|
|
714
|
+
if (!isRecord(message))
|
|
715
|
+
throw new TypeError('Pass the message onMessage got.');
|
|
716
|
+
if (str(message.eventId))
|
|
717
|
+
this.deliveredIds.delete(message.eventId);
|
|
718
|
+
}
|
|
519
719
|
/**
|
|
520
720
|
* The messages kept for this shopper, oldest first: each one that reached their chat and that no
|
|
521
721
|
* history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
|
|
@@ -546,10 +746,10 @@ export class AgentcardClient {
|
|
|
546
746
|
throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
|
|
547
747
|
}
|
|
548
748
|
}
|
|
549
|
-
async call(method, path, body, timeoutMs, stream) {
|
|
749
|
+
async call(method, path, body, timeoutMs, stream, extra = {}) {
|
|
550
750
|
let token = await this.bearer();
|
|
551
751
|
for (let attempt = 0;; attempt++) {
|
|
552
|
-
const answer = await this.send(method, path, token, body, timeoutMs, stream);
|
|
752
|
+
const answer = await this.send(method, path, token, body, timeoutMs, stream, extra);
|
|
553
753
|
if (answer.status === 401 && attempt === 0 && this.credentials) {
|
|
554
754
|
token = await this.renewAfter(token);
|
|
555
755
|
continue;
|
|
@@ -567,12 +767,13 @@ export class AgentcardClient {
|
|
|
567
767
|
throw errorOf(answer.status, answer.body);
|
|
568
768
|
}
|
|
569
769
|
}
|
|
570
|
-
async send(method, path, token, body, timeoutMs, stream) {
|
|
770
|
+
async send(method, path, token, body, timeoutMs, stream, extra = {}) {
|
|
571
771
|
let res;
|
|
572
772
|
try {
|
|
573
773
|
res = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
574
774
|
method,
|
|
575
775
|
headers: {
|
|
776
|
+
...extra,
|
|
576
777
|
authorization: `Bearer ${token}`,
|
|
577
778
|
accept: stream ? 'text/event-stream, application/json' : 'application/json',
|
|
578
779
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|