Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ Build these parts:
a merchant and an expiry. Show an approval screen in my app where the user picks
a card and approves. Run OrderIntentVerification when the card rail needs it.
3. Agent Checkouts. Start a run at a product URL with a max cost and stream its
messages. Answer its form requests. Pay its payment step with an agent card for
the exact amount. Collect store passwords with CrossmintProtectedInput.
messages. Answer each form request with all its fields in one answer. Pay its
payment step with an agent card for the exact amount. Collect each protected
field, such as a store password, with CrossmintProtectedInput.
4. Buyer details. Save the user's name, contact and shipping address as a buyer
profile, so checkouts do not stop to ask for them.
5. Agent tools. Give my agent tools to list saved cards, request an agent card,
Expand Down
26 changes: 12 additions & 14 deletions apps/web/components/chat/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
} from "@/lib/chat/tools";
import type { ChatMessage, ChatMessagePart } from "@/lib/chat/types";
import { AgentCardApproval } from "./agent-card-approval";
import { PasswordRequest } from "./password-request";
import { ProtectedRequest } from "./protected-request";
import { AddCard } from "./add-card";
import { BuyerDetailsRequest } from "./buyer-details-request";
import { PaymentChoiceInThread } from "./payment-choice";
Expand All @@ -38,7 +38,7 @@ import {
isToolError,
isCheckoutPart,
messageText,
passwordRequestOf,
protectedRequestOf,
productFor,
sentBackFirst,
toolBusy,
Expand All @@ -54,7 +54,7 @@ export interface MessageProps {
/** True while this message is still streaming in. */
streaming: boolean;
onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void;
onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void;
onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void;
onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void;
Expand All @@ -76,7 +76,7 @@ export function Message({
message,
streaming,
onApprovalOutcome,
onPasswordOutcome,
onProtectedInputOutcome,
onCardSaved,
onBuyerDetails,
onPaymentChoice,
Expand Down Expand Up @@ -106,7 +106,7 @@ export function Message({
part={part}
streaming={streaming}
onApprovalOutcome={onApprovalOutcome}
onPasswordOutcome={onPasswordOutcome}
onProtectedInputOutcome={onProtectedInputOutcome}
onCardSaved={onCardSaved}
onBuyerDetails={onBuyerDetails}
onPaymentChoice={onPaymentChoice}
Expand Down Expand Up @@ -198,7 +198,7 @@ function Part({
part,
streaming,
onApprovalOutcome,
onPasswordOutcome,
onProtectedInputOutcome,
onCardSaved,
onBuyerDetails,
onPaymentChoice,
Expand All @@ -212,7 +212,7 @@ function Part({
part: ChatMessagePart;
streaming: boolean;
onApprovalOutcome: MessageProps["onApprovalOutcome"];
onPasswordOutcome: MessageProps["onPasswordOutcome"];
onProtectedInputOutcome: MessageProps["onProtectedInputOutcome"];
onCardSaved: MessageProps["onCardSaved"];
onBuyerDetails: MessageProps["onBuyerDetails"];
onPaymentChoice: MessageProps["onPaymentChoice"];
Expand Down Expand Up @@ -329,18 +329,16 @@ function Part({
</ActivityLine>
);

// The store asks for a password: Crossmint's field in a dialog, never words.
// The store asks for secrets: Crossmint's fields in a dialog, never words.
case "tool-await_protected_input": {
const request = passwordRequestOf(part, watches);
const request = protectedRequestOf(part, watches);
if (request && (part.state === "input-available" || part.state === "output-available")) {
return (
<PasswordRequest
<ProtectedRequest
toolCallId={part.toolCallId}
checkoutId={request.checkoutId}
requestId={request.requestId}
domain={request.domain}
request={request}
output={part.state === "output-available" ? part.output : undefined}
onOutcome={onPasswordOutcome}
onOutcome={onProtectedInputOutcome}
/>
);
}
Expand Down
6 changes: 3 additions & 3 deletions apps/web/components/chat/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface MessagesProps {
messages: ChatMessage[];
status: ChatStatus;
onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void;
onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void;
onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void;
onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void;
Expand All @@ -42,7 +42,7 @@ export function Messages({
messages,
status,
onApprovalOutcome,
onPasswordOutcome,
onProtectedInputOutcome,
onCardSaved,
onBuyerDetails,
onPaymentChoice,
Expand Down Expand Up @@ -71,7 +71,7 @@ export function Messages({
message={m}
streaming={status === "streaming" && i === messages.length - 1}
onApprovalOutcome={onApprovalOutcome}
onPasswordOutcome={onPasswordOutcome}
onProtectedInputOutcome={onProtectedInputOutcome}
onCardSaved={onCardSaved}
onBuyerDetails={onBuyerDetails}
onPaymentChoice={onPaymentChoice}
Expand Down
69 changes: 44 additions & 25 deletions apps/web/components/chat/parts.ts
Comment thread
mPaella marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,17 @@ export function stopReason(
};
}
const action = view.pendingUserAction;
// A password: the chat shows Crossmint's protected field, never a question in words.
if (view.status === "awaiting_input" && action?.protected && !asked.has(action.id)) {
// Secrets: the chat shows Crossmint's protected fields, never a question in words.
const secure = view.protectedRequest;
if (view.status === "awaiting_input" && secure && !asked.has(secure.requestId)) {
return {
checkoutId: view.id,
status: "awaiting_password",
password: {
requestId: action.id,
question: action.question || "The store asks for your password.",
...(action.protected.merchant ? { domain: action.protected.merchant.domain } : {}),
status: "awaiting_protected_input",
protectedInput: {
requestId: secure.requestId,
question: secure.question || "The store asks for a secret.",
secrets: secure.fields.map((f) => f.label),
...(secure.merchantDomain ? { domain: secure.merchantDomain } : {}),
},
};
}
Expand All @@ -158,7 +160,8 @@ export function stopReason(
requestId: action.id,
question: action.question || view.rendered?.title || "The store needs an answer.",
...(action.expiresAt ? { expiresAt: action.expiresAt } : {}),
responseSchema: action.responseSchema as Record<string, unknown>,
// The model answers by key; the handling is always standard here.
fields: action.fields.map(({ key, label, required, input }) => ({ key, label, required, input })),
...(asksPasswordInForm(action)
? { note: PASSWORD_IN_FORM_NOTE }
: asksCardInForm(action)
Expand All @@ -180,9 +183,9 @@ export function watchedHere(message: ChatMessage, part: ChatMessagePart): boolea
return message.parts.some((p) => p.type === "tool-watch_checkout" && p.input?.checkoutId === id);
}

/** What the agent is told when a store asks for a password in a plain form. */
/** What the agent is told when a store asks for a password in a plain field. */
const PASSWORD_IN_FORM_NOTE =
"This asks for the user's password in a plain form. Never ask the user for it and never send it: it would pass through the chat, and the store's agent does not use a password sent that way. Tell the user in one line that this store wants them to sign in, which cannot be done safely here, and ask whether to check out as a guest instead (answer_checkout with action alternative) or stop (action decline).";
"This asks for the user's password in a plain field. Never ask the user for it and never send it: it would pass through the chat, and the store's agent does not use a password sent that way. Tell the user in one line that this store wants them to sign in, which cannot be done safely here, and ask whether to check out as a guest instead (answer_checkout with action alternative) or stop (action decline).";

/** What the agent is told when a store asks for card details in a plain form, not a payment request. */
const CARD_IN_FORM_NOTE =
Expand All @@ -200,8 +203,8 @@ export interface WatchIndex {
sites: Map<string, CheckoutSite>;
/** Each checkout's first `watch_checkout`, by tool call id. */
firstWatch: Map<string, string>;
/** Password requests a watch handed back, by requestId: the store the password is for, from the checkout itself. */
passwords: Map<string, { checkoutId: string; domain?: string }>;
/** Protected requests a watch handed back, by requestId: the store the secrets are for, from the checkout itself. */
protectedInputs: Map<string, { checkoutId: string; question: string; secrets: string[]; domain?: string }>;
/**
* Watches whose card the next watch takes over: the stretch stopped on a
* question the agent answered itself, with nothing shown in between, so the
Expand Down Expand Up @@ -229,7 +232,7 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex {
paymentRequests: new Set(),
sites: new Map(),
firstWatch: new Map(),
passwords: new Map(),
protectedInputs: new Map(),
absorbed: new Set(),
carried: new Map(),
products: new Map(),
Expand Down Expand Up @@ -274,11 +277,13 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex {
if (!shown) index.shown.set(out.checkoutId, (shown = new Set()));
for (const u of out.updates ?? []) shown.add(u.id);
if (out.question) index.asked.add(out.question.requestId);
if (out.password) {
index.asked.add(out.password.requestId);
index.passwords.set(out.password.requestId, {
if (out.protectedInput) {
index.asked.add(out.protectedInput.requestId);
index.protectedInputs.set(out.protectedInput.requestId, {
checkoutId: out.checkoutId,
...(out.password.domain ? { domain: out.password.domain } : {}),
question: out.protectedInput.question,
secrets: out.protectedInput.secrets,
...(out.protectedInput.domain ? { domain: out.protectedInput.domain } : {}),
});
}
if (out.payment) index.paymentRequests.add(out.payment.requestId);
Expand All @@ -287,23 +292,36 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex {
return index;
}

/** An `await_protected_input` call, as its card shows it. */
export interface ProtectedRequestSummary {
checkoutId: string;
requestId: string;
/** The store's question: "Sign in to shop.example to continue." */
question: string;
/** The secrets it asks for, by label: "Password". */
secrets: string[];
domain: string;
}

/**
* An `await_protected_input` call, with what its card needs: the checkout,
* the request, and the store the password is for. The store comes from the
* the request, what it asks for, and the store. All of it comes from the
* watch that handed the request back, else the checkout's own site, never
* from the model's arguments: the password is bound to it.
* from the model's arguments.
*/
export function passwordRequestOf(
export function protectedRequestOf(
part: ChatMessagePart,
watches: WatchIndex,
): { checkoutId: string; requestId: string; domain: string } | undefined {
): ProtectedRequestSummary | undefined {
if (part.type !== "tool-await_protected_input" || part.state === "input-streaming")
return undefined;
const requestId = part.input?.requestId;
const known = requestId ? watches.passwords.get(requestId) : undefined;
const known = requestId ? watches.protectedInputs.get(requestId) : undefined;
if (!requestId || !known) return undefined;
const domain = known.domain ?? watches.sites.get(known.checkoutId)?.host;
return domain ? { checkoutId: known.checkoutId, requestId, domain } : undefined;
return domain
? { checkoutId: known.checkoutId, requestId, question: known.question, secrets: known.secrets, domain }
: undefined;
}

/**
Expand All @@ -317,7 +335,7 @@ export function stoppedForUser(outcome: CheckoutOutcome | undefined): boolean {
return (
outcome?.status === "awaiting_input" ||
outcome?.status === "awaiting_payment" ||
outcome?.status === "awaiting_password"
outcome?.status === "awaiting_protected_input"
);
}

Expand Down Expand Up @@ -347,6 +365,7 @@ export function runSteps(opts: {
switch (out.status) {
case "awaiting_input":
case "awaiting_payment":
case "awaiting_protected_input":
break;
case "succeeded":
steps.push({
Expand Down Expand Up @@ -552,7 +571,7 @@ export function toolTitle(type: string): string {
"tool-get_agent_card": "Checking an agent card",
"tool-request_agent_card": "Requesting an agent card",
"tool-await_agent_card_approval": "Waiting for your approval",
"tool-await_protected_input": "Waiting for your password",
"tool-await_protected_input": "Waiting for your answer",
"tool-await_saved_card": "Waiting for your card",
"tool-await_buyer_details": "Waiting for your details",
"tool-await_payment_choice": "Waiting for how you want to pay",
Expand Down
Loading
Loading