@agent-cards/commerce-sdk 0.5.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -11
- package/dist/ack.d.ts +36 -8
- package/dist/ack.js +179 -16
- package/dist/adapters.js +1 -0
- package/dist/buy-tool.d.ts +79 -5
- package/dist/buy-tool.js +206 -29
- package/dist/client.d.ts +93 -7
- package/dist/client.js +158 -12
- package/dist/definition.d.ts +14 -6
- package/dist/definition.js +24 -8
- package/dist/events.d.ts +12 -0
- package/dist/events.js +79 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/listen.d.ts +93 -0
- package/dist/listen.js +496 -0
- package/dist/model-view.d.ts +38 -6
- package/dist/model-view.js +51 -9
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -1,14 +1,55 @@
|
|
|
1
|
+
import { type AgentcardListener, type ListenOptions } from './listen.js';
|
|
1
2
|
/** The fetch the client calls: Node 18 and later have one built in. */
|
|
2
3
|
export type FetchLike = (url: string, init: {
|
|
3
4
|
method: string;
|
|
4
5
|
headers: Record<string, string>;
|
|
5
6
|
body?: string;
|
|
6
7
|
signal?: AbortSignal;
|
|
7
|
-
}) => Promise<
|
|
8
|
+
}) => Promise<FetchLikeResponse>;
|
|
9
|
+
/**
|
|
10
|
+
* The answer a fetch returns, as far as the client reads it. `headers` and `body` are optional: a
|
|
11
|
+
* fetch without them is read whole with `text()`, and a streamed purchase answer still arrives, at its end.
|
|
12
|
+
*/
|
|
13
|
+
export interface FetchLikeResponse {
|
|
8
14
|
status: number;
|
|
9
15
|
ok: boolean;
|
|
10
16
|
text(): Promise<string>;
|
|
11
|
-
|
|
17
|
+
headers?: {
|
|
18
|
+
get(name: string): string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
body?: {
|
|
21
|
+
getReader(): {
|
|
22
|
+
read(): Promise<{
|
|
23
|
+
done: boolean;
|
|
24
|
+
value?: Uint8Array;
|
|
25
|
+
}>;
|
|
26
|
+
cancel?(): Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
} | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What the purchase turn is doing while it runs: `searching` the stores, `building_cart`, `checking_out`,
|
|
32
|
+
* or `working` on anything else. Agentcard names only the stage, never what the shopper said.
|
|
33
|
+
*/
|
|
34
|
+
export type BuyProgressStage = 'searching' | 'building_cart' | 'checking_out' | 'working';
|
|
35
|
+
/** One step of a running purchase turn. */
|
|
36
|
+
export interface BuyProgress {
|
|
37
|
+
stage: BuyProgressStage;
|
|
38
|
+
conversationId: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** The products a running turn's search found, sent the moment the search answers and before the reply is written. */
|
|
41
|
+
export interface BuyCatalogUpdate {
|
|
42
|
+
conversationId: string;
|
|
43
|
+
catalog: BuyCatalog;
|
|
44
|
+
/** When the search ran, as the answer's `catalog.as_of` names it: the same search in the answer has the same value. */
|
|
45
|
+
asOf: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** Called while a purchase turn runs. Passing either one asks Agentcard to stream the turn. */
|
|
48
|
+
export interface BuyStreamHandlers {
|
|
49
|
+
onProgress?: (progress: BuyProgress) => void;
|
|
50
|
+
/** The client waits for what this returns before it hands back the turn's answer. */
|
|
51
|
+
onCatalog?: (update: BuyCatalogUpdate) => void | Promise<void>;
|
|
52
|
+
}
|
|
12
53
|
export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
|
|
13
54
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
14
55
|
export declare const BUY_TURN_TIMEOUT_MS = 130000;
|
|
@@ -53,10 +94,14 @@ export interface BuyRequest {
|
|
|
53
94
|
new_order?: true;
|
|
54
95
|
/**
|
|
55
96
|
* The product the shopper picked from the last search's `catalog.items`, by its `id` or its `ref`:
|
|
56
|
-
* Agentcard adds exactly that item, with no new search.
|
|
97
|
+
* Agentcard adds exactly that item, with no new search. Or the store the shopper picked from the last
|
|
98
|
+
* answer's `stores.items`, by its `id`: Agentcard selects exactly that store before the turn runs.
|
|
99
|
+
* Goes with an `ask`, never with `confirm` or `new_order`.
|
|
57
100
|
*/
|
|
58
101
|
pick?: {
|
|
59
102
|
product_id: string;
|
|
103
|
+
} | {
|
|
104
|
+
store_id: string;
|
|
60
105
|
};
|
|
61
106
|
payment_source?: 'vault';
|
|
62
107
|
delivery_address?: BuyDeliveryAddress;
|
|
@@ -119,11 +164,16 @@ export interface BuyCatalogItem {
|
|
|
119
164
|
/** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
|
|
120
165
|
url: string | null;
|
|
121
166
|
}
|
|
122
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* The products the last search found. `presented` is true when this answer's reply presents them: the
|
|
169
|
+
* turn searched, or answered a repeated ask from the results it already held. False when they only
|
|
170
|
+
* stand from an earlier answer, and on an older API.
|
|
171
|
+
*/
|
|
123
172
|
export interface BuyCatalog {
|
|
124
173
|
merchant: string;
|
|
125
174
|
merchant_name: string;
|
|
126
175
|
items: BuyCatalogItem[];
|
|
176
|
+
presented: boolean;
|
|
127
177
|
}
|
|
128
178
|
/** The product a `pick` named, by its catalog id and ref, and whether the cart now holds it. */
|
|
129
179
|
export interface BuyPick {
|
|
@@ -131,6 +181,30 @@ export interface BuyPick {
|
|
|
131
181
|
ref: string | null;
|
|
132
182
|
in_cart: boolean;
|
|
133
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* One store a delivery store search found that the answer's reply names. `id` holds no link and is what
|
|
186
|
+
* `pick.store_id` takes; `store_page_url` is the store's page at the merchant. `eta_minutes` is the
|
|
187
|
+
* merchant's delivery estimate, or null when it showed none.
|
|
188
|
+
*/
|
|
189
|
+
export interface BuyStoreChoice {
|
|
190
|
+
id: string;
|
|
191
|
+
name: string;
|
|
192
|
+
store_page_url: string | null;
|
|
193
|
+
eta_minutes: number | null;
|
|
194
|
+
}
|
|
195
|
+
/** The stores an answer's reply presents to choose from, at most three, in the order the reply names them. */
|
|
196
|
+
export interface BuyStores {
|
|
197
|
+
merchant: string;
|
|
198
|
+
merchant_name: string;
|
|
199
|
+
items: BuyStoreChoice[];
|
|
200
|
+
as_of: string | null;
|
|
201
|
+
}
|
|
202
|
+
/** The store a store pick named, and whether Agentcard selected it before the turn ran. */
|
|
203
|
+
export interface BuyStorePick {
|
|
204
|
+
store_id: string;
|
|
205
|
+
name: string | null;
|
|
206
|
+
selected: boolean;
|
|
207
|
+
}
|
|
134
208
|
export interface BuyPaymentSource {
|
|
135
209
|
source: string;
|
|
136
210
|
brand: string | null;
|
|
@@ -177,6 +251,10 @@ export interface BuyTurn {
|
|
|
177
251
|
store_login_merchant_name: string | null;
|
|
178
252
|
/** The product this turn's `pick` named, and whether the cart holds it; null for a turn without one. */
|
|
179
253
|
pick: BuyPick | null;
|
|
254
|
+
/** The stores this answer's reply presents to choose from; null when it presents none, and on an older API. */
|
|
255
|
+
stores: BuyStores | null;
|
|
256
|
+
/** The store this turn's `pick.store_id` named, or an answer such as `the second one` picked; null without one. */
|
|
257
|
+
store_pick: BuyStorePick | null;
|
|
180
258
|
/** The answer as Agentcard sent it. */
|
|
181
259
|
raw: Record<string, unknown>;
|
|
182
260
|
}
|
|
@@ -260,15 +338,18 @@ export declare class AgentcardClient {
|
|
|
260
338
|
get company(): boolean;
|
|
261
339
|
/** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
|
|
262
340
|
get sandbox(): boolean | null;
|
|
263
|
-
/**
|
|
264
|
-
|
|
341
|
+
/**
|
|
342
|
+
* The access token the client sends: the shopper's own, or the company's, exchanged once and
|
|
343
|
+
* renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
|
|
344
|
+
*/
|
|
345
|
+
bearer(refused?: string): Promise<string>;
|
|
265
346
|
/**
|
|
266
347
|
* One purchase turn, sent once with a 130-second wait. A turn is a model run
|
|
267
348
|
* against live stores, so a lost answer is never sent again: read the
|
|
268
349
|
* conversation instead. Only a 401 is sent again, once, with a fresh token,
|
|
269
350
|
* because Agentcard refused it before anything ran.
|
|
270
351
|
*/
|
|
271
|
-
buy(body: BuyRequest): Promise<BuyTurn>;
|
|
352
|
+
buy(body: BuyRequest, stream?: BuyStreamHandlers): Promise<BuyTurn>;
|
|
272
353
|
/** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
|
|
273
354
|
conversation(conversationId: string, who?: Shopper): Promise<BuyConversation>;
|
|
274
355
|
/**
|
|
@@ -279,6 +360,11 @@ export declare class AgentcardClient {
|
|
|
279
360
|
mintVaultLink(input: {
|
|
280
361
|
externalUserId: string;
|
|
281
362
|
}): Promise<VaultLink>;
|
|
363
|
+
/**
|
|
364
|
+
* Hear every event Agentcard sends your company over one connection your server opens, with no
|
|
365
|
+
* webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
|
|
366
|
+
*/
|
|
367
|
+
listen(opts: ListenOptions): AgentcardListener;
|
|
282
368
|
/** A company names the shopper exactly once; a shopper's own token already names them. */
|
|
283
369
|
private checkShopper;
|
|
284
370
|
private call;
|
package/dist/client.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* token cache follows its client (packages/checkout/src/client.ts accessToken).
|
|
10
10
|
*/
|
|
11
11
|
import { isRecord } from './verify.js';
|
|
12
|
+
import { listenToAgentcard } from './listen.js';
|
|
13
|
+
const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
|
|
12
14
|
export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
|
|
13
15
|
/** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
|
|
14
16
|
export const BUY_TURN_TIMEOUT_MS = 130_000;
|
|
@@ -128,7 +130,19 @@ function catalogOf(v) {
|
|
|
128
130
|
url: pageUrl(i.url),
|
|
129
131
|
};
|
|
130
132
|
}) : [];
|
|
131
|
-
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
|
|
133
|
+
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, presented: v.presented === true };
|
|
134
|
+
}
|
|
135
|
+
/** The stores an answer presents: each one with an id and a name, and no block when none has both. */
|
|
136
|
+
function storesOf(v) {
|
|
137
|
+
if (!isRecord(v) || !Array.isArray(v.items))
|
|
138
|
+
return null;
|
|
139
|
+
const items = v.items.filter(isRecord).flatMap((i) => {
|
|
140
|
+
const id = str(i.id);
|
|
141
|
+
const name = str(i.name);
|
|
142
|
+
const eta = num(i.eta_minutes);
|
|
143
|
+
return id && name ? [{ id, name, store_page_url: pageUrl(i.store_page_url), eta_minutes: eta }] : [];
|
|
144
|
+
});
|
|
145
|
+
return items.length ? { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items, as_of: str(v.as_of) } : null;
|
|
132
146
|
}
|
|
133
147
|
function paymentSourceOf(v) {
|
|
134
148
|
return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
|
|
@@ -163,6 +177,8 @@ export function turnOf(body) {
|
|
|
163
177
|
store_login_merchant_name: str(body.store_login_merchant_name),
|
|
164
178
|
next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
|
|
165
179
|
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,
|
|
180
|
+
stores: storesOf(body.stores),
|
|
181
|
+
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,
|
|
166
182
|
raw: body,
|
|
167
183
|
};
|
|
168
184
|
}
|
|
@@ -255,6 +271,106 @@ function parseJson(text) {
|
|
|
255
271
|
return null;
|
|
256
272
|
}
|
|
257
273
|
}
|
|
274
|
+
/** Each event of a server-sent stream, in order, read as it arrives, or from the whole text when the fetch hands no stream. */
|
|
275
|
+
async function eachEvent(res, onEvent) {
|
|
276
|
+
let buffer = '';
|
|
277
|
+
// True once onEvent says the stream is over.
|
|
278
|
+
let over = false;
|
|
279
|
+
const drain = (final) => {
|
|
280
|
+
buffer = buffer.replace(/\r\n?/g, '\n');
|
|
281
|
+
for (let end = buffer.indexOf('\n\n'); end >= 0 || (final && buffer.trim() !== ''); end = buffer.indexOf('\n\n')) {
|
|
282
|
+
const block = end >= 0 ? buffer.slice(0, end) : buffer;
|
|
283
|
+
buffer = end >= 0 ? buffer.slice(end + 2) : '';
|
|
284
|
+
let event = 'message';
|
|
285
|
+
const data = [];
|
|
286
|
+
for (const line of block.split('\n')) {
|
|
287
|
+
if (line.startsWith('event:'))
|
|
288
|
+
event = line.slice(6).trim();
|
|
289
|
+
else if (line.startsWith('data:'))
|
|
290
|
+
data.push(line.slice(5).replace(/^ /, ''));
|
|
291
|
+
}
|
|
292
|
+
if (data.length > 0 && onEvent(event, data.join('\n')) === true) {
|
|
293
|
+
over = true;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const reader = res.body?.getReader?.();
|
|
299
|
+
if (!reader) {
|
|
300
|
+
buffer = await res.text();
|
|
301
|
+
drain(true);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const decoder = new TextDecoder();
|
|
305
|
+
try {
|
|
306
|
+
while (!over) {
|
|
307
|
+
const chunk = await reader.read();
|
|
308
|
+
if (chunk.done)
|
|
309
|
+
break;
|
|
310
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
311
|
+
drain(false);
|
|
312
|
+
}
|
|
313
|
+
if (!over)
|
|
314
|
+
drain(true);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
if (over)
|
|
318
|
+
reader.cancel?.().catch(() => { });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
|
|
323
|
+
* status in `http_status`. Each `progress` and `catalog` goes to its handler on the way. A stream that
|
|
324
|
+
* breaks or ends before its envelope, once it named the conversation, reads as a turn past its budget
|
|
325
|
+
* (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
|
|
326
|
+
*/
|
|
327
|
+
async function readStreamedTurn(res, stream, timeoutMs) {
|
|
328
|
+
let envelope = null;
|
|
329
|
+
let conversationId = null;
|
|
330
|
+
const delivering = [];
|
|
331
|
+
let failure = null;
|
|
332
|
+
try {
|
|
333
|
+
await eachEvent(res, (event, text) => {
|
|
334
|
+
const data = parseJson(text);
|
|
335
|
+
if (!data)
|
|
336
|
+
return;
|
|
337
|
+
conversationId = str(data.conversation_id) ?? conversationId;
|
|
338
|
+
if (event === 'envelope') {
|
|
339
|
+
envelope = data;
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (event === 'progress' && typeof data.stage === 'string' && PROGRESS_STAGES.has(data.stage) && stream.onProgress) {
|
|
343
|
+
try {
|
|
344
|
+
stream.onProgress({ stage: data.stage, conversationId: str(data.conversation_id) });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// The app's display failing never fails the turn.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (event === 'catalog' && stream.onCatalog && str(data.conversation_id)) {
|
|
351
|
+
const catalog = catalogOf(data.catalog);
|
|
352
|
+
if (!catalog || catalog.items.length === 0)
|
|
353
|
+
return;
|
|
354
|
+
const asOf = isRecord(data.catalog) ? str(data.catalog.as_of) : null;
|
|
355
|
+
delivering.push(Promise.resolve().then(() => stream.onCatalog({ conversationId: data.conversation_id, catalog, asOf })).catch(() => { }));
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
failure = error;
|
|
361
|
+
}
|
|
362
|
+
await Promise.all(delivering);
|
|
363
|
+
const answered = envelope;
|
|
364
|
+
if (answered) {
|
|
365
|
+
const { http_status: httpStatus, ...body } = answered;
|
|
366
|
+
const status = typeof httpStatus === 'number' ? httpStatus : res.status;
|
|
367
|
+
return { status, ok: status >= 200 && status < 300, readable: true, body };
|
|
368
|
+
}
|
|
369
|
+
if (conversationId) {
|
|
370
|
+
return { status: 200, ok: true, readable: true, body: { conversation_id: conversationId, status: 'needs_input', reply: '', error_code: 'turn_timeout' } };
|
|
371
|
+
}
|
|
372
|
+
throw failure ? failureOf(failure, timeoutMs) : new AgentcardApiError(0, 'unreachable', 'Agentcard closed the purchase answer before it ended.');
|
|
373
|
+
}
|
|
258
374
|
/** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
|
|
259
375
|
export class AgentcardClient {
|
|
260
376
|
baseUrl;
|
|
@@ -291,10 +407,15 @@ export class AgentcardClient {
|
|
|
291
407
|
get sandbox() {
|
|
292
408
|
return this.learnedSandbox;
|
|
293
409
|
}
|
|
294
|
-
/**
|
|
295
|
-
|
|
410
|
+
/**
|
|
411
|
+
* The access token the client sends: the shopper's own, or the company's, exchanged once and
|
|
412
|
+
* renewed a minute before it expires. Pass a company token Agentcard refused to get a new one.
|
|
413
|
+
*/
|
|
414
|
+
async bearer(refused) {
|
|
296
415
|
if (this.shopperToken !== null)
|
|
297
416
|
return this.shopperToken;
|
|
417
|
+
if (refused !== undefined)
|
|
418
|
+
return this.renewAfter(refused);
|
|
298
419
|
if (this.token && Date.now() < this.token.expiresAt)
|
|
299
420
|
return this.token.value;
|
|
300
421
|
if (!this.inflight)
|
|
@@ -307,11 +428,14 @@ export class AgentcardClient {
|
|
|
307
428
|
* conversation instead. Only a 401 is sent again, once, with a fresh token,
|
|
308
429
|
* because Agentcard refused it before anything ran.
|
|
309
430
|
*/
|
|
310
|
-
async buy(body) {
|
|
431
|
+
async buy(body, stream) {
|
|
311
432
|
if (!isRecord(body))
|
|
312
433
|
throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
|
|
313
434
|
this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
|
|
314
|
-
|
|
435
|
+
const streamed = !!stream && (typeof stream.onProgress === 'function' || typeof stream.onCatalog === 'function');
|
|
436
|
+
// A streamed turn keeps the soft budget a JSON turn has, so it answers within BUY_TURN_TIMEOUT_MS too.
|
|
437
|
+
const path = streamed ? '/buy?stream=1&soft_timeout=1' : '/buy';
|
|
438
|
+
return turnOf(await this.call('POST', path, body, BUY_TURN_TIMEOUT_MS, streamed ? stream : undefined));
|
|
315
439
|
}
|
|
316
440
|
/** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
|
|
317
441
|
async conversation(conversationId, who = {}) {
|
|
@@ -339,6 +463,13 @@ export class AgentcardClient {
|
|
|
339
463
|
throw new AgentcardApiError(200, 'invalid_answer', 'Agentcard answered the Vault link request without a link.', answer);
|
|
340
464
|
return { id: str(answer.id) ?? '', url, expiresAt: str(answer.expires_at), reused: answer.reused === true };
|
|
341
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* Hear every event Agentcard sends your company over one connection your server opens, with no
|
|
468
|
+
* webhook endpoint: `listenToAgentcard(this, opts)`. Takes your company credentials.
|
|
469
|
+
*/
|
|
470
|
+
listen(opts) {
|
|
471
|
+
return listenToAgentcard(this, opts);
|
|
472
|
+
}
|
|
342
473
|
/** A company names the shopper exactly once; a shopper's own token already names them. */
|
|
343
474
|
checkShopper(who) {
|
|
344
475
|
const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
|
|
@@ -351,10 +482,10 @@ export class AgentcardClient {
|
|
|
351
482
|
throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
|
|
352
483
|
}
|
|
353
484
|
}
|
|
354
|
-
async call(method, path, body, timeoutMs) {
|
|
485
|
+
async call(method, path, body, timeoutMs, stream) {
|
|
355
486
|
let token = await this.bearer();
|
|
356
487
|
for (let attempt = 0;; attempt++) {
|
|
357
|
-
const answer = await this.send(method, path, token, body, timeoutMs);
|
|
488
|
+
const answer = await this.send(method, path, token, body, timeoutMs, stream);
|
|
358
489
|
if (answer.status === 401 && attempt === 0 && this.credentials) {
|
|
359
490
|
token = await this.renewAfter(token);
|
|
360
491
|
continue;
|
|
@@ -372,24 +503,39 @@ export class AgentcardClient {
|
|
|
372
503
|
throw errorOf(answer.status, answer.body);
|
|
373
504
|
}
|
|
374
505
|
}
|
|
375
|
-
async send(method, path, token, body, timeoutMs) {
|
|
506
|
+
async send(method, path, token, body, timeoutMs, stream) {
|
|
507
|
+
let res;
|
|
376
508
|
try {
|
|
377
|
-
|
|
509
|
+
res = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
378
510
|
method,
|
|
379
511
|
headers: {
|
|
380
512
|
authorization: `Bearer ${token}`,
|
|
381
|
-
accept: 'application/json',
|
|
513
|
+
accept: stream ? 'text/event-stream, application/json' : 'application/json',
|
|
382
514
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
383
515
|
},
|
|
384
516
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
385
517
|
signal: AbortSignal.timeout(timeoutMs),
|
|
386
518
|
});
|
|
387
|
-
const parsed = parseJson(await res.text());
|
|
388
|
-
return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
|
|
389
519
|
}
|
|
390
520
|
catch (error) {
|
|
391
521
|
throw failureOf(error, timeoutMs);
|
|
392
522
|
}
|
|
523
|
+
// Agentcard streams only once the turn starts: a refusal before it is plain JSON.
|
|
524
|
+
if (stream && /text\/event-stream/i.test(res.headers?.get?.('content-type') ?? ''))
|
|
525
|
+
return readStreamedTurn(res, stream, timeoutMs);
|
|
526
|
+
let text;
|
|
527
|
+
try {
|
|
528
|
+
text = await res.text();
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
throw failureOf(error, timeoutMs);
|
|
532
|
+
}
|
|
533
|
+
const parsed = parseJson(text);
|
|
534
|
+
// A fetch that hands no headers still carries a streamed answer in its text: read it whole.
|
|
535
|
+
if (stream && parsed === null && /^(?:event|data):/m.test(text)) {
|
|
536
|
+
return readStreamedTurn({ status: res.status, ok: res.ok, text: async () => text }, stream, timeoutMs);
|
|
537
|
+
}
|
|
538
|
+
return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
|
|
393
539
|
}
|
|
394
540
|
/** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
|
|
395
541
|
async renewAfter(refused) {
|
package/dist/definition.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
-
* JSON Schema with
|
|
4
|
-
* picks a product they were shown. The delivery address and the
|
|
3
|
+
* JSON Schema with five required fields, `product_id` null unless the shopper
|
|
4
|
+
* picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
|
|
5
5
|
* shopper's id are construction options, never model inputs, so a model can
|
|
6
6
|
* neither change where an order goes nor buy for someone else.
|
|
7
7
|
*/
|
|
@@ -9,8 +9,9 @@ export declare const BUY_TOOL_NAME = "buy";
|
|
|
9
9
|
/** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
|
|
10
10
|
export declare const BUY_TOOL_DESCRIPTION: string;
|
|
11
11
|
/**
|
|
12
|
-
* What the model sends: `ask`, `confirm` and `new_order` every time,
|
|
13
|
-
* shopper picks a product from the last result
|
|
12
|
+
* What the model sends: `ask`, `confirm` and `new_order` every time, `product_id`, null unless the
|
|
13
|
+
* shopper picks a product from the last result, and `store_id`, null unless they pick a store from it.
|
|
14
|
+
* Your own code may leave `product_id` and `store_id` out.
|
|
14
15
|
*/
|
|
15
16
|
export interface BuyToolInput {
|
|
16
17
|
ask: string;
|
|
@@ -18,6 +19,8 @@ export interface BuyToolInput {
|
|
|
18
19
|
new_order: boolean;
|
|
19
20
|
/** The `product_id` of the product the shopper picked from the last result's `products`, or null. */
|
|
20
21
|
product_id?: string | null;
|
|
22
|
+
/** The `store_id` of the store the shopper picked from the last result's `stores`, or null. */
|
|
23
|
+
store_id?: string | null;
|
|
21
24
|
}
|
|
22
25
|
/** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
|
|
23
26
|
export type BuyToolInputSchema = {
|
|
@@ -39,6 +42,10 @@ export type BuyToolInputSchema = {
|
|
|
39
42
|
type: ['string', 'null'];
|
|
40
43
|
description: string;
|
|
41
44
|
};
|
|
45
|
+
store_id: {
|
|
46
|
+
type: ['string', 'null'];
|
|
47
|
+
description: string;
|
|
48
|
+
};
|
|
42
49
|
};
|
|
43
50
|
required: Array<keyof BuyToolInput>;
|
|
44
51
|
additionalProperties: false;
|
|
@@ -52,8 +59,9 @@ export interface BuyToolDefinition {
|
|
|
52
59
|
export declare function buyToolDefinition(): BuyToolDefinition;
|
|
53
60
|
/**
|
|
54
61
|
* The model's input, checked against the schema: an object, or its JSON text,
|
|
55
|
-
* with `ask` a string, `confirm` and `new_order` booleans, `product_id`
|
|
56
|
-
* string, null or left out,
|
|
62
|
+
* with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
|
|
63
|
+
* `store_id` each a string, null or left out, at most one of them set, and
|
|
64
|
+
* nothing else. Throws a TypeError whose message
|
|
57
65
|
* tells the model what to send.
|
|
58
66
|
*/
|
|
59
67
|
export declare function parseBuyToolInput(value: unknown): BuyToolInput;
|
package/dist/definition.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
-
* JSON Schema with
|
|
4
|
-
* picks a product they were shown. The delivery address and the
|
|
3
|
+
* JSON Schema with five required fields, `product_id` null unless the shopper
|
|
4
|
+
* picks a product they were shown and `store_id` null unless they pick a store. The delivery address and the
|
|
5
5
|
* shopper's id are construction options, never model inputs, so a model can
|
|
6
6
|
* neither change where an order goes nor buy for someone else.
|
|
7
7
|
*/
|
|
@@ -11,6 +11,7 @@ export const BUY_TOOL_DESCRIPTION = [
|
|
|
11
11
|
'Buy what this shopper asks for at real stores.',
|
|
12
12
|
"Put the shopper's own words in `ask`.",
|
|
13
13
|
'Tell the shopper what `reply` says, in your own voice.',
|
|
14
|
+
"When `shopper_already_told_looking` is true, the shopper was already told you are working on their request, so never acknowledge it again: open with the answer or the question.",
|
|
14
15
|
'Show the shopper the cart before you confirm it.',
|
|
15
16
|
'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.',
|
|
16
17
|
'Set `confirm` to true only when the shopper just said yes to the cart in your last result, says they added the card for a cart that waits, says they approved, or asks for the approval link again.',
|
|
@@ -24,6 +25,8 @@ export const BUY_TOOL_DESCRIPTION = [
|
|
|
24
25
|
"After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
|
|
25
26
|
'Set `new_order` to true only when the shopper drops the cart.',
|
|
26
27
|
"When the shopper picks one of the `products` in your last result, by its name, its place in the list or a tap that names it, set `product_id` to that product's `product_id`, so Agentcard adds exactly that product; otherwise set it to null.",
|
|
28
|
+
"When the shopper picks one of the `stores` in your last result, by its name, its place in the list or a tap that names it, set `store_id` to that store's `store_id`, so Agentcard orders from exactly that store; otherwise set it to null. Never set both `product_id` and `store_id`.",
|
|
29
|
+
'Store links reach the shopper on their own, beside your reply, as product links do: `store_links_sent` says how many.',
|
|
27
30
|
'When `pick.in_cart` is false, the product the shopper picked is not in the cart: tell the shopper what `reply` says, and never ask them to confirm the cart.',
|
|
28
31
|
].join(' ');
|
|
29
32
|
/** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
|
|
@@ -44,17 +47,22 @@ export function buyToolDefinition() {
|
|
|
44
47
|
type: ['string', 'null'],
|
|
45
48
|
description: "The `product_id` of the product the shopper picked from the `products` in your last result. Null when they picked none.",
|
|
46
49
|
},
|
|
50
|
+
store_id: {
|
|
51
|
+
type: ['string', 'null'],
|
|
52
|
+
description: 'The `store_id` of the store the shopper picked from the `stores` in your last result. Null when they picked none.',
|
|
53
|
+
},
|
|
47
54
|
},
|
|
48
|
-
required: ['ask', 'confirm', 'new_order', 'product_id'],
|
|
55
|
+
required: ['ask', 'confirm', 'new_order', 'product_id', 'store_id'],
|
|
49
56
|
additionalProperties: false,
|
|
50
57
|
},
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
|
-
const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id']);
|
|
60
|
+
const FIELDS = new Set(['ask', 'confirm', 'new_order', 'product_id', 'store_id']);
|
|
54
61
|
/**
|
|
55
62
|
* The model's input, checked against the schema: an object, or its JSON text,
|
|
56
|
-
* with `ask` a string, `confirm` and `new_order` booleans, `product_id`
|
|
57
|
-
* string, null or left out,
|
|
63
|
+
* with `ask` a string, `confirm` and `new_order` booleans, `product_id` and
|
|
64
|
+
* `store_id` each a string, null or left out, at most one of them set, and
|
|
65
|
+
* nothing else. Throws a TypeError whose message
|
|
58
66
|
* tells the model what to send.
|
|
59
67
|
*/
|
|
60
68
|
export function parseBuyToolInput(value) {
|
|
@@ -73,7 +81,7 @@ export function parseBuyToolInput(value) {
|
|
|
73
81
|
const fields = input;
|
|
74
82
|
const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
|
|
75
83
|
if (extra !== undefined)
|
|
76
|
-
throw new TypeError(`The buy tool takes only ask, confirm, new_order and
|
|
84
|
+
throw new TypeError(`The buy tool takes only ask, confirm, new_order, product_id and store_id, and ${extra} is not one of them.`);
|
|
77
85
|
if (typeof fields.ask !== 'string')
|
|
78
86
|
throw new TypeError("Set ask to the shopper's words, as a string.");
|
|
79
87
|
if (typeof fields.confirm !== 'boolean')
|
|
@@ -84,7 +92,15 @@ export function parseBuyToolInput(value) {
|
|
|
84
92
|
if (productId !== undefined && productId !== null && typeof productId !== 'string') {
|
|
85
93
|
throw new TypeError("Set product_id to the picked product's product_id, as a string, or to null.");
|
|
86
94
|
}
|
|
87
|
-
|
|
95
|
+
const storeId = fields.store_id;
|
|
96
|
+
if (storeId !== undefined && storeId !== null && typeof storeId !== 'string') {
|
|
97
|
+
throw new TypeError("Set store_id to the picked store's store_id, as a string, or to null.");
|
|
98
|
+
}
|
|
99
|
+
const product = typeof productId === 'string' && productId.trim() !== '' ? productId.trim() : null;
|
|
100
|
+
const store = typeof storeId === 'string' && storeId.trim() !== '' ? storeId.trim() : null;
|
|
101
|
+
if (product && store)
|
|
102
|
+
throw new TypeError('Set product_id or store_id for the one thing the shopper picked, and the other to null.');
|
|
103
|
+
return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order, product_id: product, store_id: store };
|
|
88
104
|
}
|
|
89
105
|
/** A fresh copy of the tool in OpenAI's function-calling format: plain JSON, so an SDK that sends it as is sends exactly this. */
|
|
90
106
|
export function openAIBuyTool() {
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every event type Agentcard sends a company, the same list a webhook endpoint subscribes to by
|
|
3
|
+
* name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
|
|
4
|
+
* envelope and the same ids. A test holds this list to the backend's catalog.
|
|
5
|
+
*/
|
|
6
|
+
export declare const AGENTCARD_EVENT_TYPES: readonly ["card.created", "card.updated", "card.closed", "cardholder.created", "cardholder.updated", "transaction.authorized", "transaction.declined", "transaction.cleared", "transaction.voided", "balance.low", "cardholder_onboarding_session.completed", "platform_connect_session.completed", "merchant.connected", "order.placed", "order.confirmed", "order.failed", "order.updated", "purchase.message", "connection.created", "connected_card.updated", "vault.card_stored", "vault.session_linked", "vault.session_card_picked", "vault.payment_permission.updated", "action.confirmed", "action.done", "checkout_authorization.approved", "checkout_authorization.submitted", "checkout_authorization.declined", "checkout_authorization.cancelled", "checkout_authorization.expired", "checkout_authorization.duplicate_charge", "checkout_authorization.refused", "checkout_authorization.watched", "checkout_authorization.amount_mismatch", "checkout_authorization.settled", "checkout_authorization.settlement_unconfirmed", "checkout_authorization.outcome_reported", "card_flow.started", "card_flow.failed", "transfer.approved", "transfer.initiated", "transfer.completed", "transfer.failed", "transfer.released", "wallet.funded", "user_wallet.funding_detected", "user_wallet.funded", "wallet.balance.low", "recovery.requested", "recovery.completed", "recovery.rejected", "wallet.withdrawal.initiated", "wallet.withdrawal.completed", "wallet.withdrawal.failed", "identity.verification.updated", "approval.requested", "reward.earned", "reward.reversed", "wallet_link.opened"];
|
|
7
|
+
/** One event type Agentcard sends. A type Agentcard adds later still reads as its own name. */
|
|
8
|
+
export type AgentcardEventType = (typeof AGENTCARD_EVENT_TYPES)[number];
|
|
9
|
+
/** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
|
|
10
|
+
export declare function isEventPattern(pattern: string): boolean;
|
|
11
|
+
/** Whether an event type matches a filter entry, as a webhook subscription matches it. */
|
|
12
|
+
export declare function matchesEventPattern(type: string, pattern: string): boolean;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every event type Agentcard sends a company, the same list a webhook endpoint subscribes to by
|
|
3
|
+
* name. The stream (`agentcard.listen`) and a webhook endpoint carry the same types, with the same
|
|
4
|
+
* envelope and the same ids. A test holds this list to the backend's catalog.
|
|
5
|
+
*/
|
|
6
|
+
export const AGENTCARD_EVENT_TYPES = [
|
|
7
|
+
'card.created',
|
|
8
|
+
'card.updated',
|
|
9
|
+
'card.closed',
|
|
10
|
+
'cardholder.created',
|
|
11
|
+
'cardholder.updated',
|
|
12
|
+
'transaction.authorized',
|
|
13
|
+
'transaction.declined',
|
|
14
|
+
'transaction.cleared',
|
|
15
|
+
'transaction.voided',
|
|
16
|
+
'balance.low',
|
|
17
|
+
'cardholder_onboarding_session.completed',
|
|
18
|
+
'platform_connect_session.completed',
|
|
19
|
+
'merchant.connected',
|
|
20
|
+
'order.placed',
|
|
21
|
+
'order.confirmed',
|
|
22
|
+
'order.failed',
|
|
23
|
+
'order.updated',
|
|
24
|
+
'purchase.message',
|
|
25
|
+
'connection.created',
|
|
26
|
+
'connected_card.updated',
|
|
27
|
+
'vault.card_stored',
|
|
28
|
+
'vault.session_linked',
|
|
29
|
+
'vault.session_card_picked',
|
|
30
|
+
'vault.payment_permission.updated',
|
|
31
|
+
'action.confirmed',
|
|
32
|
+
'action.done',
|
|
33
|
+
'checkout_authorization.approved',
|
|
34
|
+
'checkout_authorization.submitted',
|
|
35
|
+
'checkout_authorization.declined',
|
|
36
|
+
'checkout_authorization.cancelled',
|
|
37
|
+
'checkout_authorization.expired',
|
|
38
|
+
'checkout_authorization.duplicate_charge',
|
|
39
|
+
'checkout_authorization.refused',
|
|
40
|
+
'checkout_authorization.watched',
|
|
41
|
+
'checkout_authorization.amount_mismatch',
|
|
42
|
+
'checkout_authorization.settled',
|
|
43
|
+
'checkout_authorization.settlement_unconfirmed',
|
|
44
|
+
'checkout_authorization.outcome_reported',
|
|
45
|
+
'card_flow.started',
|
|
46
|
+
'card_flow.failed',
|
|
47
|
+
'transfer.approved',
|
|
48
|
+
'transfer.initiated',
|
|
49
|
+
'transfer.completed',
|
|
50
|
+
'transfer.failed',
|
|
51
|
+
'transfer.released',
|
|
52
|
+
'wallet.funded',
|
|
53
|
+
'user_wallet.funding_detected',
|
|
54
|
+
'user_wallet.funded',
|
|
55
|
+
'wallet.balance.low',
|
|
56
|
+
'recovery.requested',
|
|
57
|
+
'recovery.completed',
|
|
58
|
+
'recovery.rejected',
|
|
59
|
+
'wallet.withdrawal.initiated',
|
|
60
|
+
'wallet.withdrawal.completed',
|
|
61
|
+
'wallet.withdrawal.failed',
|
|
62
|
+
'identity.verification.updated',
|
|
63
|
+
'approval.requested',
|
|
64
|
+
'reward.earned',
|
|
65
|
+
'reward.reversed',
|
|
66
|
+
'wallet_link.opened',
|
|
67
|
+
];
|
|
68
|
+
const KNOWN = new Set(AGENTCARD_EVENT_TYPES);
|
|
69
|
+
const FAMILIES = new Set(AGENTCARD_EVENT_TYPES.map((t) => `${t.split('.')[0]}.*`));
|
|
70
|
+
/** Whether a filter entry names something Agentcard sends: an event type, a family such as `order.*`, or `*`. */
|
|
71
|
+
export function isEventPattern(pattern) {
|
|
72
|
+
return pattern === '*' || KNOWN.has(pattern) || FAMILIES.has(pattern);
|
|
73
|
+
}
|
|
74
|
+
/** Whether an event type matches a filter entry, as a webhook subscription matches it. */
|
|
75
|
+
export function matchesEventPattern(type, pattern) {
|
|
76
|
+
if (pattern === '*' || pattern === type)
|
|
77
|
+
return true;
|
|
78
|
+
return pattern.endsWith('.*') && type.startsWith(pattern.slice(0, -1));
|
|
79
|
+
}
|