@agent-cards/commerce-sdk 0.17.0 → 0.18.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 CHANGED
@@ -242,29 +242,30 @@ Every list in the reply comes as bullet lines that start with `• `, the choice
242
242
 
243
243
  An answer that asks the shopper to choose, such as a sandwich's bread, a shirt's size or a store's own size question, carries `result.choice`: each group with every value, its option id and its extra price in `price_delta_cents`. The reply shown as written lists each value on its own `• ` line under the question, with its extra price, and so does one more `onText` message when the streamed reply named the values inside a sentence. `withChoice(text, result.choice)` lists them under any text of your own.
244
244
 
245
- ### Send a clear shopping message straight to Agentcard
245
+ ### Let Agentcard answer shopping first
246
246
 
247
- Your model's first call on most messages only decides to call the buy tool. Pass `direct: true` to `buyTool`, and each turn reads its message by rule, with no model, and sets `turn.direct` when the message goes straight to Agentcard: a request to buy or book something (`find me usb-c cables under $20`, `aa batteries on amazon`), a pick of what Agentcard just showed (a card's tap, `the first one`, `1 and 3`), or the answer to the question Agentcard just asked (the address, a code, `medium`, or a plain yes to the total it asked the shopper to confirm). Send the call yourself, and with `replyAsWritten` the turn makes no model call at all:
247
+ Send the shopper's message to Agentcard before your model runs, and run your model only when the message is yours:
248
248
 
249
249
  ```ts
250
- const turn = buy.turn(message, { onMessage: (text) => sendText(shopper.id, text) });
251
- let messages = turn.messages(history);
252
- if (turn.direct) {
253
- const id = `toolu_direct_${crypto.randomUUID().replace(/-/g, '')}`;
254
- const content = await turn.run(turn.direct.input);
255
- messages = [...messages, { role: 'assistant', content: [{ type: 'tool_use', id, name: turn.name, input: turn.direct.input }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content }] }];
256
- if (turn.replyAsWritten !== null) {
257
- await turn.say(turn.replyAsWritten);
258
- history = [...messages, { role: 'assistant', content: turn.replyAsWritten }];
259
- return;
260
- }
250
+ const turn = buy.turn(message, {
251
+ onItem: (item) => (item.kind === 'text' ? sendText(shopper.id, item.text) : sendCard(shopper.id, item.link)), // your code
252
+ });
253
+ if (await turn.handled()) {
254
+ // Agentcard answered the shopper: keep the exchange, so your model reads it on the next message.
255
+ const said = turn.items.flatMap((item) => (item.kind === 'text' ? [item.text] : [])).join('\n\n');
256
+ history = [...turn.messages(history), { role: 'assistant', content: said }];
257
+ return;
261
258
  }
262
- // Otherwise run your model on `messages` as before: it words the answer to the call already made, or decides the call itself.
259
+ // Otherwise run your model on turn.messages(history), as before.
263
260
  ```
264
261
 
265
- A turn `buy.nextStep` opens carries `turn.direct` too, with the reason `next_step`: its call reads where the purchase stands whatever the model would write, so send it the same way. With `replyAsWritten`, a step whose answer is a link, such as the approval link once the shopper saved their card, then reaches the shopper with no model call at all.
262
+ Agentcard answers a request to buy or book, with or without a verb (`Printer paper, one ream`), a pick of what it just showed (a card's tap, `the second one`, `1 and 3`), an answer to its question (the address, a code, `medium`) and a yes to a total. When its reply only asks the shopper something, the turn sends it through `onItem`, after the cards and the card, sign-in or approval link it handed over, and `handled()` resolves `true`: no model call before the search, and none after it.
263
+
264
+ Every other message comes back from Agentcard as yours in about 0.1 to 0.2 seconds, with no search, and `handled()` resolves `false`: small talk, a question, a change of mind such as `no` or `wait`, and anything about the account or an order. An answer your model has to word, such as an order placed or a refusal, resolves `false` too, and `turn.messages(history)` hands your model that answer with the shopper's message, so it words it and calls the tool no second time.
265
+
266
+ `handled()` sends one call, once, and a second call resolves as the first did. A turn `buy.nextStep` opened resolves `false` and sends nothing: take it as "Take the next step once the shopper finishes one outside the chat" shows.
266
267
 
267
- Everything else leaves `turn.direct` null for your model: a question, small talk, a change of mind such as `no` or `wait`, a command to the cart, and anything about the account or an order. A pick or an answer goes straight only when the shopper's last turn ended on Agentcard's answer, so a reply to something your model said reaches your model. `directCall(text, context)` applies the same rule to one message, for an app that keeps its own state. The option is off by default, because it moves the decision of when to call Agentcard from your model into the tool.
268
+ You no longer need `direct` or `replyAsWritten` for this. They still work as they did: `direct: true` sets `turn.direct` to the call the tool's own rule reads in a message (`directCall(text, context)`), and `replyAsWritten: true` sets `turn.replyAsWritten`, for an app that sends the call itself. An app that never calls `handled()` sends nothing until its model calls the tool.
268
269
 
269
270
  ### Hand the app the turn in order
270
271
 
@@ -677,6 +677,16 @@ export interface BuyToolTurn extends Omit<BuyTool, 'instantAck' | 'instantReacti
677
677
  * is set; when it is null, hand your model the call and its result, and its words answer the shopper.
678
678
  */
679
679
  readonly direct: DirectCall | null;
680
+ /**
681
+ * Sends the shopper's message to Agentcard and resolves `true` when Agentcard answered the shopper: a request to buy, a
682
+ * pick of what it showed, an answer to its question or a yes to a total, whose reply only asks the shopper something
683
+ * and went out with `say()`. Resolves `false` when your model answers: Agentcard read the message as yours, such as
684
+ * small talk or a question about an order, in well under a second and with no model call, or it answered with an order
685
+ * placed or a refusal for your model to word, which `messages(history)` then hands it. Call it once, before your model
686
+ * runs; a second call resolves as the first did. A turn `buy.nextStep` opened, or one opened while a call still runs,
687
+ * resolves `false` and sends nothing.
688
+ */
689
+ handled(): Promise<boolean>;
680
690
  /**
681
691
  * Every message and link the turn handed over, in the order they went out: the instant reply, each
682
692
  * link, each message of Agentcard's that `onText` took, and each message written through `messageStream()`
@@ -712,6 +722,8 @@ export type BuyToolTurnMessage = {
712
722
  * or an OpenAI Responses item, they come as an assistant message of their own.
713
723
  */
714
724
  export declare function turnMessages<M extends object>(history: readonly M[] | null | undefined, told: readonly string[], shopperText: string): Array<M | BuyToolTurnMessage>;
725
+ /** The note a message carries to the model when Agentcard already answered it and left the words to the model (`handled()`). */
726
+ export declare function handedNote(answer: string): string;
715
727
  /**
716
728
  * The words a `nextStep` turn hands the model as the user's message: the event, marked as no one's
717
729
  * typing, and the instruction to take the next step now in one short message, without the filler of
package/dist/buy-tool.js CHANGED
@@ -75,6 +75,10 @@ export function turnMessages(history, told, shopperText) {
75
75
  out.push({ role: 'user', content: shopperText });
76
76
  return out;
77
77
  }
78
+ /** The note a message carries to the model when Agentcard already answered it and left the words to the model (`handled()`). */
79
+ export function handedNote(answer) {
80
+ return `[Agentcard already answered this message with the buy tool. Its answer, for you to tell the shopper in your words, without calling the tool again for it: ${answer}]`;
81
+ }
78
82
  /** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
79
83
  const modelText = (message) => stripLinks(String(message.text ?? ''));
80
84
  /** How often the history held a message's text when the message first went in to the model. */
@@ -533,6 +537,13 @@ function needsCard(link, body) {
533
537
  envelope: body,
534
538
  };
535
539
  }
540
+ /**
541
+ * One shopper message's turn: the calls the model makes for it share its number, its instant reply and
542
+ * the callbacks its links and answers go to. `links` and `results` collect for a turn `buy.turn` opened,
543
+ * and are null for a call made straight on the tool.
544
+ */
545
+ /** What a call throws when Agentcard handed the message back to the app's model (`BuyToolTurn.handled`). */
546
+ const HANDED_BACK = Symbol('handed back');
536
547
  /** The way a turn hands over a link, or undefined when it has nowhere to send one. */
537
548
  function senderOf(t) {
538
549
  if (!t.sendLink && !t.links)
@@ -1674,7 +1685,9 @@ export function buyTool(options) {
1674
1685
  // A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
1675
1686
  // shopper's own yes to this exact cart goes in, for that cart's hash.
1676
1687
  const plan = t.event ? await stepPlan(t.event) : null;
1677
- const planned = plan && 'body' in plan ? plan.body : body;
1688
+ const handBack = t.handBack ?? null;
1689
+ t.handBack = null;
1690
+ const planned = plan && 'body' in plan ? plan.body : handBack ? { ...body, hand_back: true, last_answer: handBack } : body;
1678
1691
  const confirmSent = plan ? 'body' in plan && planned.confirm !== undefined : confirm;
1679
1692
  // Agentcard writes the line that says what it is looking for (onAck) only where the shopper can read it before the
1680
1693
  // cards and has read nothing yet: a streamed ask, with somewhere to show it, that no reaction, reply or model's words
@@ -1949,6 +1962,9 @@ export function buyTool(options) {
1949
1962
  catch (error) {
1950
1963
  refusal = error;
1951
1964
  }
1965
+ // Agentcard read the message as your model's: nothing ran, so the call ends here and the turn's model answers it.
1966
+ if (turn?.handed_back)
1967
+ throw HANDED_BACK;
1952
1968
  }
1953
1969
  // The store was still building the last request's cart: once it is in, the request that was refused goes as it was
1954
1970
  // sent, a step's own request too (a saved ask is no longer saved once its step sent it).
@@ -2433,6 +2449,18 @@ export function buyTool(options) {
2433
2449
  // the app's own error. A call that ends stops it too, since a call started means the timer would send nothing.
2434
2450
  state.dropWaitLine = () => clearTimeout(waitTimer);
2435
2451
  }
2452
+ // What Agentcard last showed and asked when the turn opened, for `handled()`: its call changes them once it answers.
2453
+ const lastAnswer = {
2454
+ answered,
2455
+ next_action: lastNextAction ?? null,
2456
+ asked: lastReply ?? null,
2457
+ products: lastAnswerProducts.map((product) => ({ product_id: product.productId, name: product.name })),
2458
+ stores: lastAnswerStores.map((store) => ({ store_id: store.storeId, name: store.name })),
2459
+ };
2460
+ const openedWhileRunning = running > 0;
2461
+ const openedNextAction = lastNextAction;
2462
+ const openedProducts = lastAnswerProducts;
2463
+ const openedStores = lastAnswerStores;
2436
2464
  if (!event)
2437
2465
  previousTurn = state;
2438
2466
  // What Agentcard told the shopper that no history the model ran with holds yet.
@@ -2471,9 +2499,70 @@ export function buyTool(options) {
2471
2499
  if (saved.length > 0)
2472
2500
  client.forgetMessages(externalUserId, saved);
2473
2501
  handed = fresh;
2474
- return turnMessages(history, fresh.map(modelText), shopperWords);
2502
+ // A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
2503
+ return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
2504
+ };
2505
+ /** Says a whole text as the turn's messages, after every item before it (`say()`). */
2506
+ const sayWhole = (whole) => {
2507
+ writesThroughTurn = true;
2508
+ firstWords(whole);
2509
+ if (state.modelTurn && /\S/.test(String(whole ?? '')))
2510
+ state.modelTurn.spoke = true;
2511
+ const reply = feedReply(text);
2512
+ for (const message of toMessages(whole))
2513
+ reply.add(message);
2514
+ const released = releaseForReply();
2515
+ released.catch(() => { });
2516
+ return reply.settled().then(() => released);
2517
+ };
2518
+ /** Agentcard's answer, as the model reads it, when `handled()` left the words to the model. */
2519
+ let handedAnswer = null;
2520
+ let handledRun = null;
2521
+ /**
2522
+ * One call to Agentcard with the shopper's message (`BuyToolTurn.handled`). A pick by its id or a yes to the total the
2523
+ * tool reads itself goes as that call; every other message goes as its words, with `hand_back`, and Agentcard decides.
2524
+ */
2525
+ const runHandled = async () => {
2526
+ const words = String(shopperText ?? '');
2527
+ if (event || openedWhileRunning || words.trim() === '')
2528
+ return false;
2529
+ const local = directCall(words, { answered, nextAction: openedNextAction, asked: lastAnswer.asked, products: openedProducts, stores: openedStores });
2530
+ const exact = !!local && (local.input.confirm || local.input.product_id !== null || local.input.store_id !== null);
2531
+ const input = exact ? local.input : { ask: words, confirm: false, new_order: false, product_id: null, store_id: null };
2532
+ if (!exact) {
2533
+ state.handBack = lastAnswer;
2534
+ // Agentcard writes the line that says what it is looking for on a search this call starts, as on a search the turn
2535
+ // sends straight (`direct`); a message it hands back gets none, since nothing runs.
2536
+ state.agentcardAcks = (ask) => ask.trim() === words.trim();
2537
+ }
2538
+ let result;
2539
+ try {
2540
+ result = await answerIn(input, state);
2541
+ }
2542
+ catch (error) {
2543
+ state.handBack = null;
2544
+ if (error === HANDED_BACK)
2545
+ return false;
2546
+ throw error;
2547
+ }
2548
+ if (result.shopper_already_read_reply === true)
2549
+ return true;
2550
+ const reply = replyAsWritten(result, { textingStyle: texting });
2551
+ // The reply goes out where the app hears the turn's messages: onItem or onMessage, else onText. With none of them, the
2552
+ // shopper would read nothing, so the app's model words the answer instead.
2553
+ if (reply !== null && (onItem || onMessage)) {
2554
+ await sayWhole(reply);
2555
+ return true;
2556
+ }
2557
+ if (reply !== null && state.onText) {
2558
+ await state.onText(reply, { conversationId: result.conversation_id });
2559
+ return true;
2560
+ }
2561
+ handedAnswer = JSON.stringify(forModel(result));
2562
+ return false;
2475
2563
  };
2476
2564
  return toolOf((raw) => answerIn(raw, state), {}, {
2565
+ handled: { value: () => (handledRun ??= runHandled()), enumerable: false },
2477
2566
  agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
2478
2567
  event: { value: event, enumerable: false },
2479
2568
  release: {
@@ -2525,21 +2614,7 @@ export function buyTool(options) {
2525
2614
  },
2526
2615
  enumerable: false,
2527
2616
  },
2528
- say: {
2529
- value: (whole) => {
2530
- writesThroughTurn = true;
2531
- firstWords(whole);
2532
- if (state.modelTurn && /\S/.test(String(whole ?? '')))
2533
- state.modelTurn.spoke = true;
2534
- const reply = feedReply(text);
2535
- for (const message of toMessages(whole))
2536
- reply.add(message);
2537
- const released = releaseForReply();
2538
- released.catch(() => { });
2539
- return reply.settled().then(() => released);
2540
- },
2541
- enumerable: false,
2542
- },
2617
+ say: { value: sayWhole, enumerable: false },
2543
2618
  });
2544
2619
  };
2545
2620
  return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
package/dist/client.d.ts CHANGED
@@ -242,6 +242,29 @@ export interface BuyRequest {
242
242
  user_id?: string;
243
243
  /** `false` asks for no line that says what Agentcard is looking for (`onAck`): the shopper already read one. */
244
244
  ack?: false;
245
+ /**
246
+ * `true` asks Agentcard to answer at once, with `handed_back` and no turn run, when the ask is not shopping: a request to
247
+ * buy, a pick of what it showed, an answer to its question or a yes to a total runs as any ask, and every other message
248
+ * is your model's. `buy.turn`'s `handled()` sends it. An older API ignores it and runs the ask.
249
+ */
250
+ hand_back?: true;
251
+ /** With `hand_back`: what Agentcard's last answer showed and asked, which decides whether a pick or an answer is shopping. */
252
+ last_answer?: BuyLastAnswer;
253
+ }
254
+ /** What the shopper's last answer from Agentcard showed and asked, sent with `hand_back`. */
255
+ export interface BuyLastAnswer {
256
+ /** True when the shopper's last turn ended on Agentcard's answer, with no turn your model answered alone since. */
257
+ answered: boolean;
258
+ next_action: string | null;
259
+ asked: string | null;
260
+ products: Array<{
261
+ product_id: string | null;
262
+ name: string;
263
+ }>;
264
+ stores: Array<{
265
+ store_id: string;
266
+ name: string;
267
+ }>;
245
268
  }
246
269
  export type BuyStatus = 'needs_input' | 'needs_approval' | 'order_placed' | 'partially_placed' | 'declined';
247
270
  export interface BuyStore {
@@ -435,6 +458,8 @@ export interface BuyTurn {
435
458
  conversation_id: string;
436
459
  /** A status Agentcard adds later reads as `needs_input`. */
437
460
  status: BuyStatus;
461
+ /** True when Agentcard answered a `hand_back` ask as your model's to answer: no turn ran, and the other fields are empty. */
462
+ handed_back?: true;
438
463
  reply: string;
439
464
  messages: string[];
440
465
  cart: BuyCart | null;
package/dist/client.js CHANGED
@@ -272,6 +272,7 @@ export function turnOf(body) {
272
272
  return {
273
273
  conversation_id: str(body.conversation_id) ?? '',
274
274
  status: typeof body.status === 'string' && STATUSES.has(body.status) ? body.status : 'needs_input',
275
+ ...(body.handed_back === true ? { handed_back: true } : {}),
275
276
  reply: typeof body.reply === 'string' ? body.reply : '',
276
277
  messages: Array.isArray(body.messages) ? body.messages.filter((m) => typeof m === 'string') : [],
277
278
  cart: cartOf(body.cart),
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, ty
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type NextStepKind, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { ORDER_STEPS, updateOrder, arrivalText, type ShopperOrder, type OrderStep, type OrderPathStep, type OrderStepState, type ArrivalTextOptions, } from './order.js';
5
5
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
6
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyChoiceGroup, type BuyChoiceOption, } from './client.js';
6
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyLastAnswer, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyChoiceGroup, type BuyChoiceOption, } from './client.js';
7
7
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
8
8
  export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
9
9
  export { buyTool, nextStepPrompt, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolFound, type BuyToolAvailability, type BuyToolProduct, type BuyToolProductAction, productAction, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",