@agent-cards/commerce-sdk 0.0.0-stage → 0.2.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/LICENSE +38 -0
- package/README.md +320 -2
- package/dist/adapters.d.ts +35 -0
- package/dist/adapters.js +23 -0
- package/dist/buy-tool.d.ts +186 -0
- package/dist/buy-tool.js +619 -0
- package/dist/client.d.ts +272 -0
- package/dist/client.js +431 -0
- package/dist/definition.d.ts +100 -0
- package/dist/definition.js +104 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +190 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/message.d.ts +78 -0
- package/dist/message.js +76 -0
- package/dist/messages.d.ts +48 -0
- package/dist/messages.js +206 -0
- package/dist/model-view.d.ts +73 -0
- package/dist/model-view.js +111 -0
- package/dist/verify.d.ts +41 -0
- package/dist/verify.js +137 -0
- package/examples/anthropic.mjs +55 -0
- package/examples/openrouter.mjs +53 -0
- package/examples/walkthrough.mjs +97 -0
- package/package.json +41 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client the buy tool calls Agentcard with. A company builds one from its
|
|
3
|
+
* client credentials, and every shopper's tool shares the one access token it
|
|
4
|
+
* exchanges them for; or a tool runs on one shopper's own access token. The
|
|
5
|
+
* global fetch is the only transport, so the package keeps no dependencies.
|
|
6
|
+
*
|
|
7
|
+
* The answer fields and the error codes follow the buy module of
|
|
8
|
+
* @agent-cards/sdk (packages/checkout/src/buy.ts: turnOf, buyError), and the
|
|
9
|
+
* token cache follows its client (packages/checkout/src/client.ts accessToken).
|
|
10
|
+
*/
|
|
11
|
+
import { isRecord } from './verify.js';
|
|
12
|
+
export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
|
|
13
|
+
/** 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
|
+
export const BUY_TURN_TIMEOUT_MS = 130_000;
|
|
15
|
+
/** The wait for every other call. */
|
|
16
|
+
const CALL_TIMEOUT_MS = 30_000;
|
|
17
|
+
/** A token is renewed this long before it expires, so it never expires mid-call. */
|
|
18
|
+
const RENEW_EARLY_MS = 60_000;
|
|
19
|
+
const NEXT_ACTIONS = new Set(['shipping_details', 'guest_details', 'add_card', 'confirm', 'verification_code', 'approve']);
|
|
20
|
+
/**
|
|
21
|
+
* A refusal, or a failure to reach Agentcard. `code` is the API's own
|
|
22
|
+
* (`user_not_connected`, `external_user_id_ambiguous`, `turn_in_progress`,
|
|
23
|
+
* `user_id_mismatch`), one read from the shape of the answer
|
|
24
|
+
* (`cart_changed`, `nothing_to_confirm`, `conversation_closed`,
|
|
25
|
+
* `conversation_not_found`), or the client's (`unreachable`, `timeout`,
|
|
26
|
+
* `oauth_failed`, `unauthorized`, `http_<status>`). `status` is 0 when
|
|
27
|
+
* Agentcard never answered.
|
|
28
|
+
*/
|
|
29
|
+
export class AgentcardApiError extends Error {
|
|
30
|
+
status;
|
|
31
|
+
code;
|
|
32
|
+
/** The error body as Agentcard sent it. */
|
|
33
|
+
body;
|
|
34
|
+
conversationId;
|
|
35
|
+
/** The carts as they stand, when the refusal names them: a changed cart's fresh hash is here. */
|
|
36
|
+
carts;
|
|
37
|
+
constructor(status, code, message, body = {}, cause) {
|
|
38
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
39
|
+
this.name = 'AgentcardApiError';
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.body = body;
|
|
43
|
+
this.conversationId = str(body.conversation_id);
|
|
44
|
+
this.carts = cartsOf(body.carts);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
48
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
49
|
+
const bool = (v) => (typeof v === 'boolean' ? v : null);
|
|
50
|
+
/**
|
|
51
|
+
* A product page's address: an absolute http or https URL, serialized the way
|
|
52
|
+
* every browser reads it, or null. A value that is no web page, such as a
|
|
53
|
+
* `javascript:` address, never reaches your code as a store's page, and
|
|
54
|
+
* neither does one with a user name before its host, which reads as one store
|
|
55
|
+
* and opens another.
|
|
56
|
+
*/
|
|
57
|
+
function pageUrl(v) {
|
|
58
|
+
const text = str(v);
|
|
59
|
+
if (!text)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(text);
|
|
63
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
64
|
+
return null;
|
|
65
|
+
return url.username === '' && url.password === '' ? url.href : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function storeOf(v) {
|
|
72
|
+
if (!isRecord(v) || !str(v.id))
|
|
73
|
+
return null;
|
|
74
|
+
return { id: v.id, ...(str(v.name) ? { name: v.name } : {}) };
|
|
75
|
+
}
|
|
76
|
+
function cartOf(v) {
|
|
77
|
+
if (!isRecord(v) || !str(v.hash) || !str(v.merchant))
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
merchant: v.merchant,
|
|
81
|
+
merchant_name: str(v.merchant_name) ?? v.merchant,
|
|
82
|
+
store: storeOf(v.store),
|
|
83
|
+
items: Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => ({
|
|
84
|
+
name: str(i.name) ?? '',
|
|
85
|
+
qty: num(i.qty) ?? 1,
|
|
86
|
+
...(num(i.priceCents) !== null ? { priceCents: i.priceCents } : {}),
|
|
87
|
+
product_id: str(i.product_id),
|
|
88
|
+
url: pageUrl(i.url),
|
|
89
|
+
})) : [],
|
|
90
|
+
serviceFeesCents: num(v.serviceFeesCents) ?? 0,
|
|
91
|
+
tipCents: num(v.tipCents) ?? 0,
|
|
92
|
+
totalCents: num(v.totalCents) ?? 0,
|
|
93
|
+
currency: str(v.currency),
|
|
94
|
+
approvedCeilingCents: num(v.approvedCeilingCents),
|
|
95
|
+
hash: v.hash,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function cartsOf(v) {
|
|
99
|
+
return Array.isArray(v) ? v.map(cartOf).filter((c) => c !== null) : [];
|
|
100
|
+
}
|
|
101
|
+
function flightOf(value) {
|
|
102
|
+
if (!isRecord(value) || !Array.isArray(value.legs))
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
...(Object.hasOwn(value, 'fareCabinClass') ? { fareCabinClass: str(value.fareCabinClass) } : {}),
|
|
106
|
+
legs: value.legs.map(value => {
|
|
107
|
+
const leg = isRecord(value) ? value : {};
|
|
108
|
+
return { origin: str(leg.origin), destination: str(leg.destination),
|
|
109
|
+
departureAt: str(leg.departureAt), arrivalAt: str(leg.arrivalAt),
|
|
110
|
+
cabinClasses: Array.isArray(leg.cabinClasses) ? leg.cabinClasses.map(str) : [] };
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function catalogOf(v) {
|
|
115
|
+
if (!isRecord(v))
|
|
116
|
+
return null;
|
|
117
|
+
const items = Array.isArray(v.items) ? v.items.filter(isRecord).map((i) => {
|
|
118
|
+
const flight = flightOf(i.flight);
|
|
119
|
+
return {
|
|
120
|
+
id: str(i.id) ?? '',
|
|
121
|
+
name: str(i.name),
|
|
122
|
+
priceCents: num(i.priceCents),
|
|
123
|
+
currency: str(i.currency),
|
|
124
|
+
image_url: str(i.image_url),
|
|
125
|
+
...(flight ? { flight } : {}),
|
|
126
|
+
store: storeOf(i.store),
|
|
127
|
+
url: pageUrl(i.url),
|
|
128
|
+
};
|
|
129
|
+
}) : [];
|
|
130
|
+
return { merchant: str(v.merchant) ?? '', merchant_name: str(v.merchant_name) ?? str(v.merchant) ?? '', items };
|
|
131
|
+
}
|
|
132
|
+
function paymentSourceOf(v) {
|
|
133
|
+
return isRecord(v) && str(v.source) ? { source: v.source, brand: str(v.brand), last4: str(v.last4) } : null;
|
|
134
|
+
}
|
|
135
|
+
const STATUSES = new Set(['needs_input', 'needs_approval', 'order_placed', 'partially_placed', 'declined']);
|
|
136
|
+
/** A turn as Agentcard answers it. */
|
|
137
|
+
export function turnOf(body) {
|
|
138
|
+
return {
|
|
139
|
+
conversation_id: str(body.conversation_id) ?? '',
|
|
140
|
+
status: typeof body.status === 'string' && STATUSES.has(body.status) ? body.status : 'needs_input',
|
|
141
|
+
reply: typeof body.reply === 'string' ? body.reply : '',
|
|
142
|
+
messages: Array.isArray(body.messages) ? body.messages.filter((m) => typeof m === 'string') : [],
|
|
143
|
+
cart: cartOf(body.cart),
|
|
144
|
+
carts: cartsOf(body.carts),
|
|
145
|
+
catalog: catalogOf(body.catalog),
|
|
146
|
+
order_id: str(body.order_id),
|
|
147
|
+
payment_source: paymentSourceOf(body.payment_source),
|
|
148
|
+
decline_code: str(body.decline_code),
|
|
149
|
+
approval_url: str(body.approval_url),
|
|
150
|
+
charge_status: str(body.charge_status),
|
|
151
|
+
error_code: str(body.error_code),
|
|
152
|
+
authorization_id: str(body.authorization_id),
|
|
153
|
+
merchant_total_cents: num(body.merchant_total_cents),
|
|
154
|
+
merchant_currency: str(body.merchant_currency),
|
|
155
|
+
add_card_url: str(body.add_card_url),
|
|
156
|
+
add_card_expires_at: str(body.add_card_expires_at),
|
|
157
|
+
placement_announced: bool(body.placement_announced),
|
|
158
|
+
approval_expires_at: str(body.approval_expires_at),
|
|
159
|
+
approval_in_progress: bool(body.approval_in_progress),
|
|
160
|
+
store_login_url: str(body.store_login_url),
|
|
161
|
+
store_login_expires_at: str(body.store_login_expires_at),
|
|
162
|
+
store_login_merchant_name: str(body.store_login_merchant_name),
|
|
163
|
+
next_action: typeof body.next_action === 'string' && NEXT_ACTIONS.has(body.next_action) ? body.next_action : null,
|
|
164
|
+
raw: body,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function conversationOf(body) {
|
|
168
|
+
const last = isRecord(body.last_checkout) ? body.last_checkout : null;
|
|
169
|
+
return {
|
|
170
|
+
conversation_id: str(body.conversation_id) ?? '',
|
|
171
|
+
status: body.status === 'closed' ? 'closed' : 'open',
|
|
172
|
+
turn_in_progress: body.turn_in_progress === true,
|
|
173
|
+
last_checkout: last ? {
|
|
174
|
+
at: str(last.at) ?? '',
|
|
175
|
+
merchant: str(last.merchant) ?? '',
|
|
176
|
+
merchant_name: str(last.merchant_name) ?? str(last.merchant) ?? '',
|
|
177
|
+
status: str(last.status) ?? '',
|
|
178
|
+
code: str(last.code),
|
|
179
|
+
message: str(last.message),
|
|
180
|
+
order_id: str(last.order_id),
|
|
181
|
+
decline_code: str(last.decline_code),
|
|
182
|
+
approval_url: str(last.approval_url),
|
|
183
|
+
approval_expires_at: str(last.approval_expires_at),
|
|
184
|
+
placement_announced: bool(last.placement_announced),
|
|
185
|
+
approval_in_progress: bool(last.approval_in_progress),
|
|
186
|
+
add_card_url: str(last.add_card_url),
|
|
187
|
+
add_card_expires_at: str(last.add_card_expires_at),
|
|
188
|
+
total_cents: num(last.total_cents),
|
|
189
|
+
} : null,
|
|
190
|
+
orders: Array.isArray(body.orders) ? body.orders.filter(isRecord).map((o) => ({
|
|
191
|
+
order_id: str(o.order_id),
|
|
192
|
+
merchant: str(o.merchant) ?? '',
|
|
193
|
+
merchant_name: str(o.merchant_name) ?? str(o.merchant) ?? '',
|
|
194
|
+
status: str(o.status) ?? '',
|
|
195
|
+
total_cents: num(o.total_cents) ?? 0,
|
|
196
|
+
placed_at: str(o.placed_at),
|
|
197
|
+
})) : [],
|
|
198
|
+
carts: cartsOf(body.carts),
|
|
199
|
+
raw: body,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** An error code as the API spells one: lower-case words joined by underscores. */
|
|
203
|
+
const CODE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
|
|
204
|
+
/**
|
|
205
|
+
* The refusal as the client reports it. Most refusals name their code; the
|
|
206
|
+
* ones the API names in prose only (a changed cart, nothing to confirm, a
|
|
207
|
+
* closed conversation) and a conversation not found get a code from the shape
|
|
208
|
+
* of the answer, so a caller branches on `code` and never on the message.
|
|
209
|
+
*/
|
|
210
|
+
function errorOf(status, body) {
|
|
211
|
+
let code;
|
|
212
|
+
let message;
|
|
213
|
+
if (isRecord(body.error)) {
|
|
214
|
+
code = str(body.error.code);
|
|
215
|
+
message = str(body.error.message);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const named = str(body.error);
|
|
219
|
+
code = str(body.code) ?? (named && CODE.test(named) ? named : null);
|
|
220
|
+
message = str(body.message) ?? (named && named !== code ? named : null) ?? str(body.error_description);
|
|
221
|
+
}
|
|
222
|
+
if (!code) {
|
|
223
|
+
const text = message ?? '';
|
|
224
|
+
if (status === 404)
|
|
225
|
+
code = 'conversation_not_found';
|
|
226
|
+
else if (status === 409 && Array.isArray(body.carts))
|
|
227
|
+
code = 'cart_changed';
|
|
228
|
+
else if (status === 409 && /nothing to confirm/i.test(text))
|
|
229
|
+
code = 'nothing_to_confirm';
|
|
230
|
+
else if (status === 409 && /closed/i.test(text))
|
|
231
|
+
code = 'conversation_closed';
|
|
232
|
+
else if (status === 401)
|
|
233
|
+
code = 'unauthorized';
|
|
234
|
+
else
|
|
235
|
+
code = `http_${status}`;
|
|
236
|
+
}
|
|
237
|
+
return new AgentcardApiError(status, code, message ?? `Agentcard answered ${status} (${code}).`, body);
|
|
238
|
+
}
|
|
239
|
+
function failureOf(error, timeoutMs) {
|
|
240
|
+
const name = typeof error === 'object' && error !== null ? error.name : undefined;
|
|
241
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
242
|
+
return new AgentcardApiError(0, 'timeout', `Agentcard did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, {}, error);
|
|
243
|
+
}
|
|
244
|
+
return new AgentcardApiError(0, 'unreachable', 'Agentcard could not be reached.', {}, error);
|
|
245
|
+
}
|
|
246
|
+
/** The answer's JSON object, or null when the text is not JSON or not an object. */
|
|
247
|
+
function parseJson(text) {
|
|
248
|
+
try {
|
|
249
|
+
const value = JSON.parse(text);
|
|
250
|
+
return isRecord(value) ? value : null;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** The client every shopper's buy tool shares. Build one per set of credentials and keep it. */
|
|
257
|
+
export class AgentcardClient {
|
|
258
|
+
baseUrl;
|
|
259
|
+
fetchFn;
|
|
260
|
+
credentials;
|
|
261
|
+
shopperToken;
|
|
262
|
+
token = null;
|
|
263
|
+
inflight = null;
|
|
264
|
+
learnedSandbox = null;
|
|
265
|
+
constructor(options) {
|
|
266
|
+
const o = (options ?? {});
|
|
267
|
+
const hasCredentials = o.clientId !== undefined || o.clientSecret !== undefined;
|
|
268
|
+
if (hasCredentials === (o.accessToken !== undefined)) {
|
|
269
|
+
throw new TypeError('Pass your company client credentials (clientId and clientSecret), or one shopper\'s accessToken, and not both.');
|
|
270
|
+
}
|
|
271
|
+
if (hasCredentials && (!str(o.clientId) || !str(o.clientSecret))) {
|
|
272
|
+
throw new TypeError('Pass clientId and clientSecret: the client credentials of your Agentcard app.');
|
|
273
|
+
}
|
|
274
|
+
if (!hasCredentials && !str(o.accessToken))
|
|
275
|
+
throw new TypeError('Pass accessToken: the shopper\'s own Agentcard access token.');
|
|
276
|
+
const fetchFn = o.fetch ?? globalThis.fetch;
|
|
277
|
+
if (typeof fetchFn !== 'function')
|
|
278
|
+
throw new TypeError('Pass fetch: this runtime has no global fetch. Node 18 and later have one.');
|
|
279
|
+
this.fetchFn = fetchFn;
|
|
280
|
+
this.baseUrl = (str(o.baseUrl) ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
281
|
+
this.credentials = hasCredentials ? { clientId: o.clientId, clientSecret: o.clientSecret } : null;
|
|
282
|
+
this.shopperToken = hasCredentials ? null : o.accessToken;
|
|
283
|
+
}
|
|
284
|
+
/** True when the client buys for many shoppers with a company's credentials; false under one shopper's own token. */
|
|
285
|
+
get company() {
|
|
286
|
+
return this.credentials !== null;
|
|
287
|
+
}
|
|
288
|
+
/** True for a sandbox client, as the token exchange said; null until the first exchange, and under a shopper's token. */
|
|
289
|
+
get sandbox() {
|
|
290
|
+
return this.learnedSandbox;
|
|
291
|
+
}
|
|
292
|
+
/** The access token the client sends: the shopper's own, or the company's, exchanged once and renewed a minute before it expires. */
|
|
293
|
+
async bearer() {
|
|
294
|
+
if (this.shopperToken !== null)
|
|
295
|
+
return this.shopperToken;
|
|
296
|
+
if (this.token && Date.now() < this.token.expiresAt)
|
|
297
|
+
return this.token.value;
|
|
298
|
+
if (!this.inflight)
|
|
299
|
+
this.inflight = this.exchange().finally(() => { this.inflight = null; });
|
|
300
|
+
return this.inflight;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* One purchase turn, sent once with a 130-second wait. A turn is a model run
|
|
304
|
+
* against live stores, so a lost answer is never sent again: read the
|
|
305
|
+
* conversation instead. Only a 401 is sent again, once, with a fresh token,
|
|
306
|
+
* because Agentcard refused it before anything ran.
|
|
307
|
+
*/
|
|
308
|
+
async buy(body) {
|
|
309
|
+
if (!isRecord(body))
|
|
310
|
+
throw new TypeError('Pass the purchase request as an object, such as { ask: "two AA battery packs" }.');
|
|
311
|
+
this.checkShopper({ externalUserId: body.external_user_id, userId: body.user_id });
|
|
312
|
+
return turnOf(await this.call('POST', '/buy', body, BUY_TURN_TIMEOUT_MS));
|
|
313
|
+
}
|
|
314
|
+
/** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
|
|
315
|
+
async conversation(conversationId, who = {}) {
|
|
316
|
+
if (!str(conversationId))
|
|
317
|
+
throw new TypeError('Pass the conversation_id a purchase answer named.');
|
|
318
|
+
this.checkShopper(who);
|
|
319
|
+
const query = who.externalUserId ? `?external_user_id=${encodeURIComponent(who.externalUserId)}`
|
|
320
|
+
: who.userId ? `?user_id=${encodeURIComponent(who.userId)}` : '';
|
|
321
|
+
return conversationOf(await this.call('GET', `/buy/conversations/${encodeURIComponent(conversationId)}${query}`, undefined, CALL_TIMEOUT_MS));
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The Vault link a shopper opens to connect to your company and add a card.
|
|
325
|
+
* Agentcard returns the link it already opened for this shopper while that
|
|
326
|
+
* link has time left, so a shopper who asks twice gets one link. Sent once.
|
|
327
|
+
*/
|
|
328
|
+
async mintVaultLink(input) {
|
|
329
|
+
if (!this.credentials)
|
|
330
|
+
throw new TypeError('A Vault link is made with your company credentials: build the client with clientId and clientSecret.');
|
|
331
|
+
const externalUserId = input?.externalUserId;
|
|
332
|
+
if (!str(externalUserId))
|
|
333
|
+
throw new TypeError('Pass externalUserId: your own id for the shopper.');
|
|
334
|
+
const answer = await this.call('POST', '/api/v2/vault_sessions', { external_user_id: externalUserId, reuse: true }, CALL_TIMEOUT_MS);
|
|
335
|
+
const url = str(answer.url);
|
|
336
|
+
if (!url)
|
|
337
|
+
throw new AgentcardApiError(200, 'invalid_answer', 'Agentcard answered the Vault link request without a link.', answer);
|
|
338
|
+
return { id: str(answer.id) ?? '', url, expiresAt: str(answer.expires_at), reused: answer.reused === true };
|
|
339
|
+
}
|
|
340
|
+
/** A company names the shopper exactly once; a shopper's own token already names them. */
|
|
341
|
+
checkShopper(who) {
|
|
342
|
+
const named = [who.externalUserId, who.userId].filter((v) => v !== undefined);
|
|
343
|
+
if (this.shopperToken !== null) {
|
|
344
|
+
if (named.length > 0)
|
|
345
|
+
throw new TypeError('A shopper\'s own access token already names the shopper. Leave externalUserId and userId out.');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (named.length !== 1 || !str(named[0])) {
|
|
349
|
+
throw new TypeError('Name the shopper once: externalUserId, your own id for them, or userId, the Agentcard user id.');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async call(method, path, body, timeoutMs) {
|
|
353
|
+
let token = await this.bearer();
|
|
354
|
+
for (let attempt = 0;; attempt++) {
|
|
355
|
+
const answer = await this.send(method, path, token, body, timeoutMs);
|
|
356
|
+
if (answer.status === 401 && attempt === 0 && this.credentials) {
|
|
357
|
+
token = await this.renewAfter(token);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (answer.ok) {
|
|
361
|
+
// An answer the client cannot read is not an empty purchase. It is refused, and never sent again.
|
|
362
|
+
if (!answer.readable) {
|
|
363
|
+
throw new AgentcardApiError(answer.status, 'invalid_answer', `Agentcard answered ${answer.status} with a body that is not a JSON object.`);
|
|
364
|
+
}
|
|
365
|
+
return answer.body;
|
|
366
|
+
}
|
|
367
|
+
if (answer.status === 401 && !this.credentials) {
|
|
368
|
+
throw new AgentcardApiError(401, 'unauthorized', 'Agentcard refused the shopper\'s access token. Sign the shopper in again.', answer.body);
|
|
369
|
+
}
|
|
370
|
+
throw errorOf(answer.status, answer.body);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async send(method, path, token, body, timeoutMs) {
|
|
374
|
+
try {
|
|
375
|
+
const res = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
376
|
+
method,
|
|
377
|
+
headers: {
|
|
378
|
+
authorization: `Bearer ${token}`,
|
|
379
|
+
accept: 'application/json',
|
|
380
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
381
|
+
},
|
|
382
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
383
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
384
|
+
});
|
|
385
|
+
const parsed = parseJson(await res.text());
|
|
386
|
+
return { status: res.status, ok: res.ok, readable: parsed !== null, body: parsed ?? {} };
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
throw failureOf(error, timeoutMs);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** After a 401: the token another call already renewed, or one new exchange, however many calls were refused at once. */
|
|
393
|
+
async renewAfter(refused) {
|
|
394
|
+
if (this.inflight)
|
|
395
|
+
return this.inflight;
|
|
396
|
+
if (this.token && this.token.value !== refused && Date.now() < this.token.expiresAt)
|
|
397
|
+
return this.token.value;
|
|
398
|
+
this.token = null;
|
|
399
|
+
return this.bearer();
|
|
400
|
+
}
|
|
401
|
+
async exchange() {
|
|
402
|
+
const { clientId, clientSecret } = this.credentials;
|
|
403
|
+
const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret });
|
|
404
|
+
let status;
|
|
405
|
+
let body;
|
|
406
|
+
try {
|
|
407
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/v2/oauth/token`, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
410
|
+
body: form.toString(),
|
|
411
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
412
|
+
});
|
|
413
|
+
status = res.status;
|
|
414
|
+
body = parseJson(await res.text()) ?? {};
|
|
415
|
+
if (!res.ok || !str(body.access_token)) {
|
|
416
|
+
const reason = str(body.error);
|
|
417
|
+
throw new AgentcardApiError(status, 'oauth_failed', `Agentcard refused the client credentials (${status}${reason ? `, ${reason}` : ''}). Check the clientId and clientSecret you passed.`, body);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
throw error instanceof AgentcardApiError ? error : failureOf(error, CALL_TIMEOUT_MS);
|
|
422
|
+
}
|
|
423
|
+
if (body.sandbox === true || body.livemode === false)
|
|
424
|
+
this.learnedSandbox = true;
|
|
425
|
+
else if (body.sandbox === false || body.livemode === true)
|
|
426
|
+
this.learnedSandbox = false;
|
|
427
|
+
const ttlMs = Math.max(60_000, (num(body.expires_in) ?? 3600) * 1000 - RENEW_EARLY_MS);
|
|
428
|
+
this.token = { value: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
429
|
+
return this.token.value;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
+
* JSON Schema with three required fields. The delivery address and the
|
|
4
|
+
* shopper's id are construction options, never model inputs, so a model can
|
|
5
|
+
* neither change where an order goes nor buy for someone else.
|
|
6
|
+
*/
|
|
7
|
+
export declare const BUY_TOOL_NAME = "buy";
|
|
8
|
+
/** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
|
|
9
|
+
export declare const BUY_TOOL_DESCRIPTION: string;
|
|
10
|
+
/** What the model sends: all three fields, every time. */
|
|
11
|
+
export interface BuyToolInput {
|
|
12
|
+
ask: string;
|
|
13
|
+
confirm: boolean;
|
|
14
|
+
new_order: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** A type, not an interface, so it fits the SDKs' own input schema types, which take any key. */
|
|
17
|
+
export type BuyToolInputSchema = {
|
|
18
|
+
type: 'object';
|
|
19
|
+
properties: {
|
|
20
|
+
ask: {
|
|
21
|
+
type: 'string';
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
confirm: {
|
|
25
|
+
type: 'boolean';
|
|
26
|
+
description: string;
|
|
27
|
+
};
|
|
28
|
+
new_order: {
|
|
29
|
+
type: 'boolean';
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
required: Array<keyof BuyToolInput>;
|
|
34
|
+
additionalProperties: false;
|
|
35
|
+
};
|
|
36
|
+
export interface BuyToolDefinition {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
input_schema: BuyToolInputSchema;
|
|
40
|
+
}
|
|
41
|
+
/** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
|
|
42
|
+
export declare function buyToolDefinition(): BuyToolDefinition;
|
|
43
|
+
/**
|
|
44
|
+
* The model's input, checked against the schema: an object, or its JSON text,
|
|
45
|
+
* with `ask` a string and `confirm` and `new_order` booleans, and nothing
|
|
46
|
+
* else. Throws a TypeError whose message tells the model what to send.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseBuyToolInput(value: unknown): BuyToolInput;
|
|
49
|
+
/** The buy tool in OpenAI's function-calling format, which the OpenAI SDK, OpenRouter's chat API and most other model APIs take. */
|
|
50
|
+
export interface OpenAIBuyTool {
|
|
51
|
+
type: 'function';
|
|
52
|
+
function: {
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
parameters: BuyToolInputSchema;
|
|
56
|
+
strict: true;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** 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. */
|
|
60
|
+
export declare function openAIBuyTool(): OpenAIBuyTool;
|
|
61
|
+
/** What a Standard Schema check returns: the model's input, checked, or why it does not fit. */
|
|
62
|
+
export type BuyToolInputCheck = {
|
|
63
|
+
readonly value: BuyToolInput;
|
|
64
|
+
readonly issues?: undefined;
|
|
65
|
+
} | {
|
|
66
|
+
readonly issues: ReadonlyArray<{
|
|
67
|
+
readonly message: string;
|
|
68
|
+
}>;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The input schema as a Standard Schema that also hands over its JSON Schema,
|
|
72
|
+
* the form the Vercel AI SDK reads from a tool's `inputSchema`. The shape is
|
|
73
|
+
* the published Standard Schema one, so the package names no schema library.
|
|
74
|
+
*/
|
|
75
|
+
export interface BuyToolStandardSchema {
|
|
76
|
+
readonly '~standard': {
|
|
77
|
+
readonly version: 1;
|
|
78
|
+
readonly vendor: string;
|
|
79
|
+
readonly validate: (value: unknown) => BuyToolInputCheck;
|
|
80
|
+
readonly jsonSchema: {
|
|
81
|
+
readonly input: (options: {
|
|
82
|
+
readonly target: string;
|
|
83
|
+
}) => BuyToolInputSchema;
|
|
84
|
+
readonly output: (options: {
|
|
85
|
+
readonly target: string;
|
|
86
|
+
}) => BuyToolInputSchema;
|
|
87
|
+
};
|
|
88
|
+
/** Read by type inference only, never set. */
|
|
89
|
+
readonly types?: {
|
|
90
|
+
readonly input: BuyToolInput;
|
|
91
|
+
readonly output: BuyToolInput;
|
|
92
|
+
} | undefined;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The Standard Schema of the tool's input. `validate` checks the model's
|
|
97
|
+
* input as `parseBuyToolInput` does, and reports a refusal in the words
|
|
98
|
+
* `refusal` gives it, so the model reads what to send.
|
|
99
|
+
*/
|
|
100
|
+
export declare function buyToolStandardSchema(refusal: (error: unknown) => string): BuyToolStandardSchema;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy tool as a model sees it: one name, one description, and a strict
|
|
3
|
+
* JSON Schema with three required fields. The delivery address and the
|
|
4
|
+
* shopper's id are construction options, never model inputs, so a model can
|
|
5
|
+
* neither change where an order goes nor buy for someone else.
|
|
6
|
+
*/
|
|
7
|
+
export const BUY_TOOL_NAME = 'buy';
|
|
8
|
+
/** What the model reads before it calls the tool. Every sentence is a rule the model follows. */
|
|
9
|
+
export const BUY_TOOL_DESCRIPTION = [
|
|
10
|
+
'Buy what this shopper asks for at real stores.',
|
|
11
|
+
"Put the shopper's own words in `ask`.",
|
|
12
|
+
'Tell the shopper what `reply` says, in your own voice.',
|
|
13
|
+
'Show the shopper the cart before you confirm it.',
|
|
14
|
+
'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.',
|
|
15
|
+
'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.',
|
|
16
|
+
'Never write a link.',
|
|
17
|
+
'Each link reaches the shopper on its own.',
|
|
18
|
+
'When `link_sent` is true, tell the shopper a link is waiting for them.',
|
|
19
|
+
'Product links reach the shopper on their own, beside your reply: when `product_links_sent` is above zero and the shopper asks for links, tell them the links are there; never write or invent a URL.',
|
|
20
|
+
'A `placing` status means the order is going through, so never ask for another approval.',
|
|
21
|
+
'Tell the shopper they need not reply only when `shopper_need_not_reply` is true.',
|
|
22
|
+
'After `needs_card` with a cart, Agentcard keeps the cart, so set `confirm` to true once the shopper says they added the card.',
|
|
23
|
+
"After `needs_card` with no cart, call this tool again with the shopper's request once they say they added a card.",
|
|
24
|
+
'Set `new_order` to true only when the shopper drops the cart.',
|
|
25
|
+
].join(' ');
|
|
26
|
+
/** A fresh copy of the tool's definition, so a caller that edits one never changes another. */
|
|
27
|
+
export function buyToolDefinition() {
|
|
28
|
+
return {
|
|
29
|
+
name: BUY_TOOL_NAME,
|
|
30
|
+
description: BUY_TOOL_DESCRIPTION,
|
|
31
|
+
input_schema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
ask: { type: 'string', description: 'What the shopper said, in their own words.' },
|
|
35
|
+
confirm: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
description: '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. False otherwise.',
|
|
38
|
+
},
|
|
39
|
+
new_order: { type: 'boolean', description: 'True only when the shopper drops the cart to start a new order. False otherwise.' },
|
|
40
|
+
},
|
|
41
|
+
required: ['ask', 'confirm', 'new_order'],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const FIELDS = new Set(['ask', 'confirm', 'new_order']);
|
|
47
|
+
/**
|
|
48
|
+
* The model's input, checked against the schema: an object, or its JSON text,
|
|
49
|
+
* with `ask` a string and `confirm` and `new_order` booleans, and nothing
|
|
50
|
+
* else. Throws a TypeError whose message tells the model what to send.
|
|
51
|
+
*/
|
|
52
|
+
export function parseBuyToolInput(value) {
|
|
53
|
+
let input = value;
|
|
54
|
+
if (typeof input === 'string') {
|
|
55
|
+
try {
|
|
56
|
+
input = JSON.parse(input);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new TypeError('The buy tool takes a JSON object with ask, confirm and new_order.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
63
|
+
throw new TypeError('The buy tool takes an object with ask, confirm and new_order.');
|
|
64
|
+
}
|
|
65
|
+
const fields = input;
|
|
66
|
+
const extra = Object.keys(fields).find((key) => !FIELDS.has(key));
|
|
67
|
+
if (extra !== undefined)
|
|
68
|
+
throw new TypeError(`The buy tool takes only ask, confirm and new_order, and ${extra} is not one of them.`);
|
|
69
|
+
if (typeof fields.ask !== 'string')
|
|
70
|
+
throw new TypeError("Set ask to the shopper's words, as a string.");
|
|
71
|
+
if (typeof fields.confirm !== 'boolean')
|
|
72
|
+
throw new TypeError('Set confirm to true or false.');
|
|
73
|
+
if (typeof fields.new_order !== 'boolean')
|
|
74
|
+
throw new TypeError('Set new_order to true or false.');
|
|
75
|
+
return { ask: fields.ask, confirm: fields.confirm, new_order: fields.new_order };
|
|
76
|
+
}
|
|
77
|
+
/** 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. */
|
|
78
|
+
export function openAIBuyTool() {
|
|
79
|
+
const { name, description, input_schema } = buyToolDefinition();
|
|
80
|
+
return { type: 'function', function: { name, description, parameters: input_schema, strict: true } };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The Standard Schema of the tool's input. `validate` checks the model's
|
|
84
|
+
* input as `parseBuyToolInput` does, and reports a refusal in the words
|
|
85
|
+
* `refusal` gives it, so the model reads what to send.
|
|
86
|
+
*/
|
|
87
|
+
export function buyToolStandardSchema(refusal) {
|
|
88
|
+
const schema = () => buyToolDefinition().input_schema;
|
|
89
|
+
return {
|
|
90
|
+
'~standard': {
|
|
91
|
+
version: 1,
|
|
92
|
+
vendor: 'agentcard',
|
|
93
|
+
validate: (value) => {
|
|
94
|
+
try {
|
|
95
|
+
return { value: parseBuyToolInput(value) };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return { issues: [{ message: refusal(error) }] };
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
jsonSchema: { input: schema, output: schema },
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|