@agent-cards/commerce-sdk 0.13.0 → 0.15.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 +45 -4
- package/dist/ack.d.ts +24 -0
- package/dist/ack.js +8 -8
- package/dist/buy-tool.d.ts +130 -13
- package/dist/buy-tool.js +347 -17
- package/dist/client.d.ts +132 -3
- package/dist/client.js +176 -17
- package/dist/definition.js +1 -0
- 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 +4 -3
- package/dist/index.js +2 -1
- package/dist/model-view.d.ts +28 -3
- package/dist/model-view.js +150 -6
- package/dist/shopper-message.d.ts +2 -2
- package/package.json +9 -1
package/dist/client.d.ts
CHANGED
|
@@ -44,20 +44,144 @@ 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
|
+
/**
|
|
55
|
+
* Whether a place a card shows has room at the time the shopper asked for: `checking` while Agentcard's live search runs,
|
|
56
|
+
* `available` once it found the place open, with the open times it named in `slots` (empty when it named none),
|
|
57
|
+
* `unavailable` when it found the place full then, and `unknown` when the search ended without saying either way. Only
|
|
58
|
+
* `available` is a place to offer as a choice. A product with no such question, such as a retail product, carries none.
|
|
59
|
+
*/
|
|
60
|
+
export type BuyAvailability = {
|
|
61
|
+
status: 'checking';
|
|
62
|
+
} | {
|
|
63
|
+
status: 'available';
|
|
64
|
+
slots: string[];
|
|
65
|
+
} | {
|
|
66
|
+
status: 'unavailable';
|
|
67
|
+
} | {
|
|
68
|
+
status: 'unknown';
|
|
69
|
+
};
|
|
70
|
+
/** What Agentcard's live search said of the places a running turn's cards show, matched to each card by its catalog `id`. */
|
|
71
|
+
export interface BuyAvailabilityUpdate {
|
|
72
|
+
conversationId: string;
|
|
73
|
+
items: Array<{
|
|
74
|
+
id: string;
|
|
75
|
+
availability: BuyAvailability;
|
|
76
|
+
}>;
|
|
77
|
+
}
|
|
78
|
+
/** How many products a running turn found so far, and how many of them it shows: "8 of 23 shown". */
|
|
79
|
+
export interface BuyFoundUpdate {
|
|
80
|
+
conversationId: string;
|
|
81
|
+
shown: number;
|
|
82
|
+
found: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 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
|
|
86
|
+
* `searching Target and Walmart` or `adding it to your cart`, and `step` names the step for an app that writes its own,
|
|
87
|
+
* such as `searching`, `adding_to_cart` or `store_checkout`. No status comes for a turn that only writes its reply.
|
|
88
|
+
*/
|
|
89
|
+
export interface BuyTurnStatus {
|
|
90
|
+
conversationId: string | null;
|
|
91
|
+
step: string;
|
|
92
|
+
text: string;
|
|
93
|
+
}
|
|
94
|
+
/** One message Agentcard finished writing while the turn runs, links included, as the answer's `messages` then holds it. */
|
|
95
|
+
export interface BuySegmentUpdate {
|
|
96
|
+
conversationId: string | null;
|
|
97
|
+
text: string;
|
|
98
|
+
}
|
|
99
|
+
/** The carts on the table the moment a running turn builds or changes one, before the message that announces it. */
|
|
100
|
+
export interface BuyCartUpdate {
|
|
101
|
+
conversationId: string;
|
|
102
|
+
/** The cart a confirm would place, as the answer's `cart` names it, or null when none would be. */
|
|
103
|
+
cart: BuyCart | null;
|
|
104
|
+
/** Every cart on the table, oldest first, as the answer's `carts`. */
|
|
105
|
+
carts: BuyCart[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A piece of a message while Agentcard writes it. A piece can hold words Agentcard drops before the message is done, such
|
|
109
|
+
* as a line that only says it is looking, so show the pieces as a draft and replace them with each `onSegment` message.
|
|
110
|
+
*/
|
|
111
|
+
export interface BuyDeltaUpdate {
|
|
112
|
+
conversationId: string | null;
|
|
113
|
+
text: string;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Called while a purchase turn runs. Passing any one asks Agentcard to stream the turn. `onCatalog`, `onSegment`,
|
|
117
|
+
* `onCart` and `onDelta` hear their events one at a time, in the order Agentcard sent them, each once the one before it
|
|
118
|
+
* settled, so a piece of the next message never reaches the app before the message ahead of it. The client waits for
|
|
119
|
+
* the last of them before it hands back the turn's answer.
|
|
120
|
+
*/
|
|
49
121
|
export interface BuyStreamHandlers {
|
|
50
122
|
onProgress?: (progress: BuyProgress) => void;
|
|
51
|
-
/**
|
|
123
|
+
/** Each step the turn starts, as a line to show while the shopper waits. */
|
|
124
|
+
onStatus?: (status: BuyTurnStatus) => void;
|
|
125
|
+
/** How many products the turn found, each time the count grows. */
|
|
126
|
+
onFound?: (update: BuyFoundUpdate) => void | Promise<void>;
|
|
52
127
|
onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
|
|
128
|
+
/** Each message Agentcard writes, the moment it is written. */
|
|
129
|
+
onSegment?: (update: BuySegmentUpdate) => void | Promise<void>;
|
|
130
|
+
/** The carts, each time the turn builds or changes one. */
|
|
131
|
+
onCart?: (update: BuyCartUpdate) => void | Promise<void>;
|
|
132
|
+
/** Each piece of a message as Agentcard writes it. Passing it asks Agentcard for the pieces (`?stream=deltas`). */
|
|
133
|
+
onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
|
|
134
|
+
/** Whether the places the turn's cards show have room, once Agentcard's live search says, after the cards it changes. */
|
|
135
|
+
onAvailability?: (update: BuyAvailabilityUpdate) => void | Promise<void>;
|
|
53
136
|
}
|
|
54
137
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
55
138
|
/** 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
139
|
export declare const BUY_TURN_TIMEOUT_MS = 130000;
|
|
140
|
+
/** The W3C trace context a `POST /buy` carries, so the backend's spans join the caller's trace. */
|
|
141
|
+
export interface TraceContext {
|
|
142
|
+
traceparent: string;
|
|
143
|
+
tracestate?: string;
|
|
144
|
+
}
|
|
145
|
+
/** The part of `@opentelemetry/api` the client calls: pass the module itself as `otel`. */
|
|
146
|
+
export interface OtelApi {
|
|
147
|
+
trace: {
|
|
148
|
+
getTracer(name: string): OtelTracer;
|
|
149
|
+
};
|
|
150
|
+
context: {
|
|
151
|
+
active(): unknown;
|
|
152
|
+
};
|
|
153
|
+
propagation: {
|
|
154
|
+
inject(context: unknown, carrier: Record<string, string>): void;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
interface OtelTracer {
|
|
158
|
+
startActiveSpan<T>(name: string, options: {
|
|
159
|
+
kind?: number;
|
|
160
|
+
attributes?: Record<string, string | boolean>;
|
|
161
|
+
}, fn: (span: OtelSpan) => T): T;
|
|
162
|
+
}
|
|
163
|
+
interface OtelSpan {
|
|
164
|
+
setAttribute(key: string, value: string | number | boolean): unknown;
|
|
165
|
+
setStatus(status: {
|
|
166
|
+
code: number;
|
|
167
|
+
message?: string;
|
|
168
|
+
}): unknown;
|
|
169
|
+
end(): void;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* How the client finds the trace a `POST /buy` belongs to. `otel` is `@opentelemetry/api`: by default the client
|
|
173
|
+
* loads it when the app installed it, and `false` turns that off. `traceContext` names the trace by hand, for an app
|
|
174
|
+
* without OpenTelemetry: return its `traceparent`, or nothing to send none. The active OpenTelemetry context wins.
|
|
175
|
+
*/
|
|
176
|
+
export interface AgentcardTraceOptions {
|
|
177
|
+
otel?: OtelApi | false;
|
|
178
|
+
traceContext?: () => TraceContext | string | null | undefined;
|
|
179
|
+
}
|
|
57
180
|
/** A company's client credentials, or one shopper's own access token. */
|
|
58
181
|
export type AgentcardClientOptions = {
|
|
59
182
|
baseUrl?: string;
|
|
60
183
|
fetch?: FetchLike;
|
|
184
|
+
trace?: AgentcardTraceOptions;
|
|
61
185
|
} & ({
|
|
62
186
|
clientId: string;
|
|
63
187
|
clientSecret: string;
|
|
@@ -155,7 +279,8 @@ export interface BuyCart {
|
|
|
155
279
|
items: BuyCartLine[];
|
|
156
280
|
/** The items alone, or null from an older API. */
|
|
157
281
|
subtotalCents: number | null;
|
|
158
|
-
serviceFeesCents: number;
|
|
282
|
+
serviceFeesCents: number | null;
|
|
283
|
+
serviceFeesStatus?: 'known' | 'pending' | 'unknown';
|
|
159
284
|
/** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
|
|
160
285
|
merchantFeeLines: BuyCartFeeLine[] | null;
|
|
161
286
|
tipCents: number;
|
|
@@ -202,6 +327,8 @@ export interface BuyCatalogItem {
|
|
|
202
327
|
store: BuyStore | null;
|
|
203
328
|
/** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
|
|
204
329
|
url: string | null;
|
|
330
|
+
/** Whether a place has room at the asked time (BuyAvailability). Absent on a product with no such question. */
|
|
331
|
+
availability?: BuyAvailability;
|
|
205
332
|
}
|
|
206
333
|
/**
|
|
207
334
|
* The products the last search found. `presented` is true when this answer's reply presents them: the
|
|
@@ -393,6 +520,7 @@ export declare class AgentcardClient {
|
|
|
393
520
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
394
521
|
private readonly inbox;
|
|
395
522
|
private readonly deliveredIds;
|
|
523
|
+
private readonly trace;
|
|
396
524
|
constructor(options: AgentcardClientOptions);
|
|
397
525
|
/** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
|
|
398
526
|
get company(): boolean;
|
|
@@ -459,3 +587,4 @@ export declare class AgentcardClient {
|
|
|
459
587
|
private renewAfter;
|
|
460
588
|
private exchange;
|
|
461
589
|
}
|
|
590
|
+
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', 'onAvailability'];
|
|
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
|
|
@@ -111,6 +144,13 @@ function feeLinesOf(v) {
|
|
|
111
144
|
const lines = v.filter(isRecord).flatMap((f) => (str(f.label) && num(f.amountCents) !== null ? [{ label: f.label, amountCents: f.amountCents }] : []));
|
|
112
145
|
return lines.length ? lines : null;
|
|
113
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
|
+
}
|
|
114
154
|
function cartOf(v) {
|
|
115
155
|
if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
|
|
116
156
|
return null;
|
|
@@ -135,7 +175,7 @@ function cartOf(v) {
|
|
|
135
175
|
};
|
|
136
176
|
}) : [],
|
|
137
177
|
subtotalCents: num(v.subtotalCents),
|
|
138
|
-
|
|
178
|
+
...feesOf(v),
|
|
139
179
|
merchantFeeLines: feeLinesOf(v.merchantFeeLines),
|
|
140
180
|
tipCents: num(v.tipCents) ?? 0,
|
|
141
181
|
totalCents: num(v.totalCents) ?? 0,
|
|
@@ -164,7 +204,12 @@ function flightOf(value) {
|
|
|
164
204
|
function catalogOf(v) {
|
|
165
205
|
if (!isRecord(v))
|
|
166
206
|
return null;
|
|
167
|
-
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) => {
|
|
168
213
|
const flight = flightOf(i.flight);
|
|
169
214
|
return {
|
|
170
215
|
id: str(i.id) ?? '',
|
|
@@ -178,9 +223,33 @@ function catalogOf(v) {
|
|
|
178
223
|
...(flight ? { flight } : {}),
|
|
179
224
|
store: storeOf(i.store),
|
|
180
225
|
url: pageUrl(i.url),
|
|
226
|
+
...availabilityField(i),
|
|
181
227
|
};
|
|
182
228
|
}) : [];
|
|
183
|
-
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A place's availability as Agentcard marks it: `checking`, `open` or `available` with the times in `slots`, `none` or
|
|
232
|
+
* `unavailable`, and `unknown`. A mark this version does not know reads as `unknown`, so no place is offered on a guess.
|
|
233
|
+
*/
|
|
234
|
+
function availabilityOf(v) {
|
|
235
|
+
const mark = v.availability;
|
|
236
|
+
if (mark === 'checking')
|
|
237
|
+
return { status: 'checking' };
|
|
238
|
+
if (mark === 'open' || mark === 'available') {
|
|
239
|
+
const slots = Array.isArray(v.slots) ? v.slots.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
|
|
240
|
+
return { status: 'available', slots };
|
|
241
|
+
}
|
|
242
|
+
if (mark === 'none' || mark === 'unavailable')
|
|
243
|
+
return { status: 'unavailable' };
|
|
244
|
+
return typeof mark === 'string' ? { status: 'unknown' } : null;
|
|
245
|
+
}
|
|
246
|
+
function availabilityField(v) {
|
|
247
|
+
const availability = availabilityOf(v);
|
|
248
|
+
return availability ? { availability } : {};
|
|
249
|
+
}
|
|
250
|
+
/** A count Agentcard sent: a whole number, never negative. Null otherwise. */
|
|
251
|
+
function countOf(v) {
|
|
252
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
|
|
184
253
|
}
|
|
185
254
|
/** The stores an answer presents: each one with an id and a name, and no block when none has both. */
|
|
186
255
|
function storesOf(v) {
|
|
@@ -376,14 +445,21 @@ async function eachEvent(res, onEvent) {
|
|
|
376
445
|
}
|
|
377
446
|
/**
|
|
378
447
|
* A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
|
|
379
|
-
* status in `http_status`. Each `progress`
|
|
380
|
-
*
|
|
448
|
+
* status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `cart` and `venue_availability` goes to its handler
|
|
449
|
+
* on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
|
|
450
|
+
* ends before its envelope, once it named the conversation, reads as a turn past its budget
|
|
381
451
|
* (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
|
|
382
452
|
*/
|
|
383
453
|
async function readStreamedTurn(res, stream, timeoutMs) {
|
|
384
454
|
let envelope = null;
|
|
385
455
|
let conversationId = null;
|
|
386
|
-
|
|
456
|
+
// The catalog, segment, cart and delta handlers run one after another, in the order their events arrived, so an app
|
|
457
|
+
// never shows a message above the cards it names, nor a piece of the next message before the message ahead of it. The
|
|
458
|
+
// stream is read on meanwhile, and the answer waits for the last one. A handler that throws costs only itself.
|
|
459
|
+
let delivering = Promise.resolve();
|
|
460
|
+
const deliver = (hand) => {
|
|
461
|
+
delivering = delivering.then(() => hand()).catch(() => { });
|
|
462
|
+
};
|
|
387
463
|
let failure = null;
|
|
388
464
|
try {
|
|
389
465
|
await eachEvent(res, (event, text) => {
|
|
@@ -391,6 +467,8 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
391
467
|
if (!data)
|
|
392
468
|
return;
|
|
393
469
|
conversationId = str(data.conversation_id) ?? conversationId;
|
|
470
|
+
// The conversation this event belongs to: its own, or the one an earlier event of the same turn named.
|
|
471
|
+
const named = conversationId;
|
|
394
472
|
if (event === 'envelope') {
|
|
395
473
|
envelope = data;
|
|
396
474
|
return true;
|
|
@@ -403,19 +481,68 @@ async function readStreamedTurn(res, stream, timeoutMs) {
|
|
|
403
481
|
// The app's display failing never fails the turn.
|
|
404
482
|
}
|
|
405
483
|
}
|
|
406
|
-
else if (event === '
|
|
484
|
+
else if (event === 'status' && typeof data.text === 'string' && data.text.trim() !== '' && typeof data.step === 'string' && stream.onStatus) {
|
|
485
|
+
try {
|
|
486
|
+
stream.onStatus({ conversationId: named, step: data.step, text: data.text });
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// The app's display failing never fails the turn.
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else if (event === 'delta' && typeof data.text === 'string' && data.text !== '' && stream.onDelta) {
|
|
493
|
+
deliver(() => stream.onDelta({ conversationId: named, text: data.text }));
|
|
494
|
+
}
|
|
495
|
+
else if (event === 'catalog' && (stream.onCatalog || stream.onFound) && str(data.conversation_id)) {
|
|
407
496
|
const catalog = catalogOf(data.catalog);
|
|
408
497
|
if (!catalog || catalog.items.length === 0)
|
|
409
498
|
return;
|
|
410
|
-
const
|
|
411
|
-
|
|
499
|
+
const block = data.catalog;
|
|
500
|
+
const asOf = str(block.as_of);
|
|
501
|
+
const shown = countOf(block.shown);
|
|
502
|
+
const found = countOf(block.found);
|
|
503
|
+
const update = { conversationId: data.conversation_id, catalog, asOf, shown, found, more: catalogItemsOf(block.more) };
|
|
504
|
+
// The count follows the cards it counts, so an app never shows "8 of 23" above no cards, and it settles before the
|
|
505
|
+
// next event, so a later count never lands before an earlier one. A card display that throws never costs the count.
|
|
506
|
+
deliver(async () => {
|
|
507
|
+
try {
|
|
508
|
+
await stream.onCatalog?.(update);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// The app's display failing never fails the turn.
|
|
512
|
+
}
|
|
513
|
+
if (shown !== null && found !== null && found >= shown)
|
|
514
|
+
await stream.onFound?.({ conversationId: update.conversationId, shown, found });
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
else if (event === 'find' && stream.onFound && str(data.conversation_id)) {
|
|
518
|
+
// Find mode's count from the product index: the cards it shows of the products it found.
|
|
519
|
+
const shown = countOf(data.shown);
|
|
520
|
+
const found = countOf(data.found);
|
|
521
|
+
if (shown !== null && found !== null && found >= shown)
|
|
522
|
+
deliver(() => stream.onFound({ conversationId: data.conversation_id, shown, found }));
|
|
523
|
+
}
|
|
524
|
+
else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
|
|
525
|
+
deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
|
|
526
|
+
}
|
|
527
|
+
else if (event === 'venue_availability' && stream.onAvailability && str(data.conversation_id) && Array.isArray(data.items)) {
|
|
528
|
+
const items = data.items.filter(isRecord).flatMap((i) => {
|
|
529
|
+
const id = str(i.id);
|
|
530
|
+
const availability = availabilityOf(i);
|
|
531
|
+
return id && availability ? [{ id, availability }] : [];
|
|
532
|
+
});
|
|
533
|
+
if (items.length)
|
|
534
|
+
deliver(() => stream.onAvailability({ conversationId: data.conversation_id, items }));
|
|
535
|
+
}
|
|
536
|
+
else if (event === 'cart' && stream.onCart && str(data.conversation_id)) {
|
|
537
|
+
const update = { conversationId: data.conversation_id, cart: cartOf(data.cart), carts: cartsOf(data.carts) };
|
|
538
|
+
deliver(() => stream.onCart(update));
|
|
412
539
|
}
|
|
413
540
|
});
|
|
414
541
|
}
|
|
415
542
|
catch (error) {
|
|
416
543
|
failure = error;
|
|
417
544
|
}
|
|
418
|
-
await
|
|
545
|
+
await delivering;
|
|
419
546
|
const answered = envelope;
|
|
420
547
|
if (answered) {
|
|
421
548
|
const { http_status: httpStatus, ...body } = answered;
|
|
@@ -439,6 +566,7 @@ export class AgentcardClient {
|
|
|
439
566
|
/** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
|
|
440
567
|
inbox = new Map();
|
|
441
568
|
deliveredIds = new Set();
|
|
569
|
+
trace;
|
|
442
570
|
constructor(options) {
|
|
443
571
|
const o = (options ?? {});
|
|
444
572
|
const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
|
|
@@ -457,6 +585,7 @@ export class AgentcardClient {
|
|
|
457
585
|
this.baseUrl = (str(o.baseUrl) ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
458
586
|
this.credentials = hasCredentials ? { clientId: o.clientId, clientSecret: o.clientSecret } : null;
|
|
459
587
|
this.shopperToken = hasCredentials ? null : o.accessToken;
|
|
588
|
+
this.trace = isRecord(o.trace) ? o.trace : {};
|
|
460
589
|
}
|
|
461
590
|
/** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
|
|
462
591
|
get company() {
|
|
@@ -491,10 +620,39 @@ export class AgentcardClient {
|
|
|
491
620
|
if (!isRecord(body))
|
|
492
621
|
throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
|
|
493
622
|
this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
|
|
494
|
-
const streamed = !!stream && (
|
|
495
|
-
// A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
|
|
496
|
-
|
|
497
|
-
|
|
623
|
+
const streamed = !!stream && STREAM_HANDLERS.some((name) => typeof stream[name] === 'function');
|
|
624
|
+
// A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too. The pieces of
|
|
625
|
+
// each message go out only to a caller that asks for them.
|
|
626
|
+
const path = !streamed ? '/buy' : typeof stream.onDelta === 'function' ? '/buy?stream=deltas&soft_timeout=1' : '/buy?stream=1&soft_timeout=1';
|
|
627
|
+
const otel = this.trace.otel === false ? null : this.trace.otel ?? await loadOtel();
|
|
628
|
+
const run = async () => {
|
|
629
|
+
const headers = traceHeaders(otel, this.trace.traceContext);
|
|
630
|
+
return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined, headers));
|
|
631
|
+
};
|
|
632
|
+
if (!otel)
|
|
633
|
+
return run();
|
|
634
|
+
// One `sdk.buy` span per call, a client span (kind 2) the backend's `buy.request` hangs under. It names the
|
|
635
|
+
// conversation and how the call ended, never what the shopper said.
|
|
636
|
+
const attributes = { 'agentcard.streamed': streamed };
|
|
637
|
+
const conversationId = str(body.conversation_id);
|
|
638
|
+
if (conversationId)
|
|
639
|
+
attributes['agentcard.conversation_id'] = conversationId;
|
|
640
|
+
return otel.trace.getTracer('commerce-sdk').startActiveSpan('sdk.buy', { kind: 2, attributes }, async (span) => {
|
|
641
|
+
try {
|
|
642
|
+
const turn = await run();
|
|
643
|
+
if (turn.conversation_id)
|
|
644
|
+
span.setAttribute('agentcard.conversation_id', turn.conversation_id);
|
|
645
|
+
span.setAttribute('agentcard.buy_status', turn.status);
|
|
646
|
+
return turn;
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
span.setStatus({ code: 2, message: error instanceof AgentcardApiError ? error.code : 'error' });
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
span.end();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
498
656
|
}
|
|
499
657
|
/** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
|
|
500
658
|
async conversation(conversationId, who = {}) {
|
|
@@ -618,10 +776,10 @@ export class AgentcardClient {
|
|
|
618
776
|
throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
|
|
619
777
|
}
|
|
620
778
|
}
|
|
621
|
-
async call(method, path, body, timeoutMs, stream) {
|
|
779
|
+
async call(method, path, body, timeoutMs, stream, extra = {}) {
|
|
622
780
|
let token = await this.bearer();
|
|
623
781
|
for (let attempt = 0;; attempt++) {
|
|
624
|
-
const answer = await this.send(method, path, token, body, timeoutMs, stream);
|
|
782
|
+
const answer = await this.send(method, path, token, body, timeoutMs, stream, extra);
|
|
625
783
|
if (answer.status === 401 && attempt === 0 && this.credentials) {
|
|
626
784
|
token = await this.renewAfter(token);
|
|
627
785
|
continue;
|
|
@@ -639,12 +797,13 @@ export class AgentcardClient {
|
|
|
639
797
|
throw errorOf(answer.status, answer.body);
|
|
640
798
|
}
|
|
641
799
|
}
|
|
642
|
-
async send(method, path, token, body, timeoutMs, stream) {
|
|
800
|
+
async send(method, path, token, body, timeoutMs, stream, extra = {}) {
|
|
643
801
|
let res;
|
|
644
802
|
try {
|
|
645
803
|
res = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
646
804
|
method,
|
|
647
805
|
headers: {
|
|
806
|
+
...extra,
|
|
648
807
|
authorization: `Bearer ${token}`,
|
|
649
808
|
accept: stream ? 'text/event-stream, application/json' : 'application/json',
|
|
650
809
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
package/dist/definition.js
CHANGED
|
@@ -13,6 +13,7 @@ export const BUY_TOOL_DESCRIPTION = [
|
|
|
13
13
|
'When the shopper sends a link to a product, such as a buy.trychannel3.com link, put the whole link in `ask` exactly as they sent it, query string included, with their words beside it: Agentcard buys that exact product from the link, so never describe the link in words instead.',
|
|
14
14
|
'Tell the shopper what `reply` says, in your own voice, except the products and stores it lists that reach the shopper as cards.',
|
|
15
15
|
"When `shopper_already_told_looking` is true, the shopper already knows you are working on their request, from a reply or a 👀 on their message, so never acknowledge it again: open with the answer or the question.",
|
|
16
|
+
'When `shopper_already_read_reply` is true, the shopper already read `reply` as Agentcard wrote it, so never say it again in any words: answer with only what `reply` leaves out, in one short line, or with nothing.',
|
|
16
17
|
'Show the shopper the cart before you confirm it.',
|
|
17
18
|
'When `cart_building` is set, the store is still building that cart: tell the shopper in one short line that its total is on its way, never ask them to confirm it, and confirm it only after they say yes to the total in the message Agentcard sends next.',
|
|
18
19
|
'Ask the shopper for exactly one thing per message: the step `next_action` names, even when `reply` lists more. Never mention a later step, and never ask for the next one until the shopper has done this one.',
|
package/dist/direct.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BuyNextAction } from './client.js';
|
|
2
|
+
import type { BuyToolInput } from './definition.js';
|
|
3
|
+
/** Why a message goes straight to Agentcard. */
|
|
4
|
+
export type DirectReason = 'product_ask' | 'pick' | 'answer' | 'confirm';
|
|
5
|
+
/** The buy call for a message that goes straight to Agentcard, and why it does. */
|
|
6
|
+
export interface DirectCall {
|
|
7
|
+
input: BuyToolInput;
|
|
8
|
+
reason: DirectReason;
|
|
9
|
+
}
|
|
10
|
+
/** What Agentcard last told the shopper, which decides whether a pick or an answer goes straight. */
|
|
11
|
+
export interface DirectContext {
|
|
12
|
+
/**
|
|
13
|
+
* True when the shopper's last turn ended on an answer from Agentcard: its last call returned, and
|
|
14
|
+
* no turn since ran without one. False or left out, only a request to buy goes straight.
|
|
15
|
+
*/
|
|
16
|
+
answered?: boolean;
|
|
17
|
+
/** The step Agentcard's last answer asked for, its `next_action`. */
|
|
18
|
+
nextAction?: BuyNextAction | null;
|
|
19
|
+
/** Agentcard's last reply, the question a short answer answers. */
|
|
20
|
+
asked?: string | null;
|
|
21
|
+
/** The products Agentcard's last answer showed, in order, with the `product_id` a pick sends. */
|
|
22
|
+
products?: ReadonlyArray<{
|
|
23
|
+
productId: string | null;
|
|
24
|
+
name: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** The stores Agentcard's last answer showed, with the `store_id` a pick sends. */
|
|
27
|
+
stores?: ReadonlyArray<{
|
|
28
|
+
storeId: string;
|
|
29
|
+
name: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* True when the message asks to buy or book something, read with no context: a shopping verb with what
|
|
34
|
+
* to get, a product link, a product at a named store, or a table. A question, small talk, a pick or an
|
|
35
|
+
* answer, a command to the cart, the shopper's own details and anything about the account read false.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isProductAsk(text: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The call a shopper's message makes straight to Agentcard, with no model deciding it, or null when
|
|
40
|
+
* your model decides: see the top of this file for what goes straight. `ask` carries the shopper's
|
|
41
|
+
* words as they sent them, a confirm goes in only for a plain yes to the total Agentcard asked them to
|
|
42
|
+
* confirm, and a pick names its `product_id` or `store_id` only when the message names one exactly.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* directCall('find me usb-c cables under $20'); // { input: { ask: 'find me usb-c cables under $20', confirm: false, ... }, reason: 'product_ask' }
|
|
46
|
+
* directCall('what time is it'); // null
|
|
47
|
+
* directCall('yes', { answered: true, nextAction: 'confirm' }); // { input: { ask: 'yes', confirm: true, ... }, reason: 'confirm' }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare function directCall(shopperText: string, context?: DirectContext): DirectCall | null;
|