Skip to main content

ap2_merchant_agent/
ap2_merchant_agent.rs

1//! AP2 Merchant Agent Example
2//!
3//! Demonstrates a merchant agent that handles the AP2 (Agent Payments Protocol) flow:
4//!
5//! 1. Receives an **IntentMandate** from a shopper agent ("find red shoes under $100")
6//! 2. Responds with a **CartMandate** artifact containing matching products + payment details
7//! 3. Receives a **PaymentMandate** with the shopper's chosen payment method
8//! 4. Returns a **PaymentReceipt** confirming the transaction
9//!
10//! If a plain text message is received (no AP2 mandate), the agent responds with
11//! a catalog listing.
12//!
13//! Run with:
14//!   cargo run --example ap2_merchant_agent --features ap2
15
16use a2a_agents::core::{AgentBuilder, BuildError};
17use a2a_ap2::{
18    CartContents, CartMandate, PaymentCurrencyAmount, PaymentDetailsInit, PaymentItem,
19    PaymentMethodData, PaymentOptions, PaymentReceipt, PaymentRequest, PaymentStatus, Success,
20    Validate, cart_mandate_artifact, find_intent_mandate, find_payment_mandate,
21    payment_receipt_to_part,
22};
23use a2a_rs::{
24    InMemoryTaskStorage,
25    domain::{A2AError, Artifact, Message, Part, Role, Task, TaskState, TaskStatus},
26    port::AsyncMessageHandler,
27};
28use async_trait::async_trait;
29use uuid::Uuid;
30
31/// A simple product in our catalog.
32struct Product {
33    sku: &'static str,
34    name: &'static str,
35    price: f64,
36    description: &'static str,
37}
38
39/// Hardcoded shoe catalog for the demo.
40const CATALOG: &[Product] = &[
41    Product {
42        sku: "SHOE-001",
43        name: "Classic Red Sneakers",
44        price: 79.99,
45        description: "Comfortable everyday sneakers in vibrant red",
46    },
47    Product {
48        sku: "SHOE-002",
49        name: "Blue Running Shoes",
50        price: 119.99,
51        description: "Lightweight performance running shoes",
52    },
53    Product {
54        sku: "SHOE-003",
55        name: "Black Leather Boots",
56        price: 149.99,
57        description: "Durable leather boots for all weather",
58    },
59    Product {
60        sku: "SHOE-004",
61        name: "White Canvas Slip-Ons",
62        price: 49.99,
63        description: "Casual slip-on shoes in classic white canvas",
64    },
65];
66
67/// Merchant handler that processes AP2 payment flows.
68#[derive(Clone)]
69struct MerchantHandler;
70
71impl MerchantHandler {
72    /// Build a CartMandate from matching products.
73    fn build_cart(&self, products: &[&Product], cart_id: &str) -> Result<CartMandate, A2AError> {
74        let display_items: Vec<PaymentItem> = products
75            .iter()
76            .map(|p| PaymentItem {
77                label: p.name.to_string(),
78                amount: PaymentCurrencyAmount {
79                    currency: "USD".to_string(),
80                    value: p.price,
81                },
82                pending: None,
83                refund_period: 30,
84            })
85            .collect();
86
87        let total_value: f64 = products.iter().map(|p| p.price).sum();
88
89        let cart = CartMandate {
90            contents: CartContents {
91                id: cart_id.to_string(),
92                user_cart_confirmation_required: true,
93                payment_request: PaymentRequest {
94                    method_data: vec![
95                        PaymentMethodData {
96                            supported_methods: "CARD".to_string(),
97                            data: None,
98                        },
99                        PaymentMethodData {
100                            supported_methods: "google-pay".to_string(),
101                            data: None,
102                        },
103                    ],
104                    details: PaymentDetailsInit {
105                        id: format!("order-{}", cart_id),
106                        display_items,
107                        shipping_options: None,
108                        modifiers: None,
109                        total: PaymentItem {
110                            label: "Order Total".to_string(),
111                            amount: PaymentCurrencyAmount {
112                                currency: "USD".to_string(),
113                                value: total_value,
114                            },
115                            pending: None,
116                            refund_period: 30,
117                        },
118                    },
119                    options: Some(PaymentOptions {
120                        request_payer_name: Some(true),
121                        request_payer_email: Some(true),
122                        request_payer_phone: None,
123                        request_shipping: Some(true),
124                        shipping_type: Some("shipping".to_string()),
125                    }),
126                    shipping_address: None,
127                },
128                cart_expiry: "2026-12-31T23:59:59Z".to_string(),
129                merchant_name: "AP2 Shoe Store".to_string(),
130            },
131            merchant_authorization: None,
132        };
133
134        cart.contents
135            .validate()
136            .map_err(|e| A2AError::InvalidRequest(e.to_string()))?;
137        Ok(cart)
138    }
139
140    /// Match products from catalog based on the shopper's intent description.
141    fn match_products(&self, description: &str) -> Vec<&Product> {
142        let desc_lower = description.to_lowercase();
143
144        // Extract budget if mentioned (e.g., "under $100")
145        let budget: Option<f64> = desc_lower
146            .find("under $")
147            .or_else(|| desc_lower.find("under $"))
148            .and_then(|pos| {
149                let after = &desc_lower[pos + 7..];
150                after
151                    .split(|c: char| !c.is_ascii_digit() && c != '.')
152                    .next()
153                    .and_then(|s| s.parse().ok())
154            });
155
156        CATALOG
157            .iter()
158            .filter(|p| {
159                let name_lower = p.name.to_lowercase();
160                let desc_match = desc_lower.split_whitespace().any(|word| {
161                    name_lower.contains(word) || p.description.to_lowercase().contains(word)
162                });
163                let within_budget = budget.map(|b| p.price < b).unwrap_or(true);
164                desc_match && within_budget
165            })
166            .collect()
167    }
168
169    /// Format catalog as a text listing.
170    fn catalog_text(&self) -> String {
171        let mut text = String::from("Welcome to the AP2 Shoe Store! Here's our catalog:\n\n");
172        for p in CATALOG {
173            text.push_str(&format!(
174                "  {} - {} (${:.2})\n    {}\n\n",
175                p.sku, p.name, p.price, p.description
176            ));
177        }
178        text.push_str(
179            "To purchase, send an AP2 IntentMandate describing what you'd like, \
180             or tell me what you're looking for!",
181        );
182        text
183    }
184}
185
186#[async_trait]
187impl AsyncMessageHandler for MerchantHandler {
188    async fn process_message(
189        &self,
190        task_id: &str,
191        message: &Message,
192        _session_id: Option<&str>,
193    ) -> Result<Task, A2AError> {
194        let context_id = message.context_id.clone();
195
196        // --- Try AP2 IntentMandate flow ---
197        if let Some(intent) =
198            find_intent_mandate(message).map_err(|e| A2AError::InvalidRequest(e.to_string()))?
199        {
200            tracing::info!(
201                task_id,
202                description = %intent.natural_language_description,
203                "Received IntentMandate"
204            );
205
206            let matched = self.match_products(&intent.natural_language_description);
207
208            if matched.is_empty() {
209                let response = Message::builder()
210                    .role(Role::Agent)
211                    .parts(vec![Part::text(
212                        "Sorry, no products matched your request. Try browsing our catalog!"
213                            .to_string(),
214                    )])
215                    .message_id(Uuid::new_v4().to_string())
216                    .context_id(context_id.clone())
217                    .build();
218
219                return Ok(Task::builder()
220                    .id(task_id.to_string())
221                    .context_id(context_id)
222                    .status(TaskStatus::new(
223                        TaskState::Completed,
224                        Some(response.clone()),
225                    ))
226                    .history(vec![message.clone(), response])
227                    .build());
228            }
229
230            // Build cart from matched products
231            let cart_id = Uuid::new_v4().to_string();
232            let cart = self.build_cart(&matched, &cart_id)?;
233
234            let cart_artifact = cart_mandate_artifact(
235                &cart,
236                Uuid::new_v4().to_string(),
237                Some("Shopping Cart".to_string()),
238            )
239            .map_err(|e| A2AError::InvalidRequest(e.to_string()))?;
240
241            let summary = format!(
242                "Found {} item(s) matching your request. Total: ${:.2}.\n\
243                 Please review the cart artifact and send a PaymentMandate to complete the purchase.",
244                matched.len(),
245                matched.iter().map(|p| p.price).sum::<f64>(),
246            );
247
248            let response = Message::builder()
249                .role(Role::Agent)
250                .parts(vec![Part::text(summary)])
251                .message_id(Uuid::new_v4().to_string())
252                .context_id(context_id.clone())
253                .build();
254
255            return Ok(Task::builder()
256                .id(task_id.to_string())
257                .context_id(context_id)
258                .status(TaskStatus::new(
259                    TaskState::InputRequired,
260                    Some(response.clone()),
261                ))
262                .history(vec![message.clone(), response])
263                .artifacts(vec![cart_artifact])
264                .build());
265        }
266
267        // --- Try AP2 PaymentMandate flow ---
268        if let Some(payment) =
269            find_payment_mandate(message).map_err(|e| A2AError::InvalidRequest(e.to_string()))?
270        {
271            tracing::info!(
272                task_id,
273                mandate_id = %payment.payment_mandate_contents.payment_mandate_id,
274                "Received PaymentMandate — processing payment"
275            );
276
277            let receipt = PaymentReceipt {
278                payment_mandate_id: payment.payment_mandate_contents.payment_mandate_id.clone(),
279                timestamp: chrono::Utc::now().to_rfc3339(),
280                payment_id: format!("pay-{}", Uuid::new_v4()),
281                amount: payment
282                    .payment_mandate_contents
283                    .payment_details_total
284                    .amount
285                    .clone(),
286                payment_status: PaymentStatus::Success(Success {
287                    merchant_confirmation_id: format!("mc-{}", Uuid::new_v4()),
288                    psp_confirmation_id: Some(format!("psp-{}", Uuid::new_v4())),
289                    network_confirmation_id: None,
290                }),
291                payment_method_details: None,
292            };
293
294            receipt
295                .validate()
296                .map_err(|e| A2AError::InvalidRequest(e.to_string()))?;
297
298            let receipt_part = payment_receipt_to_part(&receipt)
299                .map_err(|e| A2AError::InvalidRequest(e.to_string()))?;
300
301            let response = Message::builder()
302                .role(Role::Agent)
303                .parts(vec![
304                    Part::text(format!(
305                        "Payment of ${:.2} {} processed successfully! \
306                         Confirmation: {}",
307                        receipt.amount.value,
308                        receipt.amount.currency,
309                        match &receipt.payment_status {
310                            PaymentStatus::Success(s) => s.merchant_confirmation_id.clone(),
311                            _ => "N/A".to_string(),
312                        },
313                    )),
314                    receipt_part,
315                ])
316                .message_id(Uuid::new_v4().to_string())
317                .context_id(context_id.clone())
318                .build();
319
320            let receipt_artifact = Artifact {
321                artifact_id: Uuid::new_v4().to_string(),
322                name: "Payment Receipt".to_string(),
323                description: String::new(),
324                parts: response.parts.clone(),
325                metadata: buffa::MessageField::none(),
326                extensions: Vec::new(),
327                ..Default::default()
328            };
329
330            return Ok(Task::builder()
331                .id(task_id.to_string())
332                .context_id(context_id)
333                .status(TaskStatus::new(
334                    TaskState::Completed,
335                    Some(response.clone()),
336                ))
337                .history(vec![message.clone(), response])
338                .artifacts(vec![receipt_artifact])
339                .build());
340        }
341
342        // --- Plain text: show catalog ---
343        let response = Message::builder()
344            .role(Role::Agent)
345            .parts(vec![Part::text(self.catalog_text())])
346            .message_id(Uuid::new_v4().to_string())
347            .context_id(context_id.clone())
348            .build();
349
350        Ok(Task::builder()
351            .id(task_id.to_string())
352            .context_id(context_id)
353            .status(TaskStatus::new(
354                TaskState::Completed,
355                Some(response.clone()),
356            ))
357            .history(vec![message.clone(), response])
358            .build())
359    }
360}
361
362#[tokio::main]
363async fn main() -> Result<(), BuildError> {
364    tracing_subscriber::fmt()
365        .with_env_filter(
366            tracing_subscriber::EnvFilter::from_default_env()
367                .add_directive(tracing::Level::INFO.into()),
368        )
369        .init();
370
371    println!("AP2 Merchant Agent - Shoe Store Demo");
372    println!("=====================================");
373    println!();
374    println!("This agent implements the AP2 payment protocol flow:");
375    println!("  1. Send a text message to browse the catalog");
376    println!("  2. Send an IntentMandate to search for products");
377    println!("  3. Review the CartMandate artifact returned");
378    println!("  4. Send a PaymentMandate to complete the purchase");
379    println!("  5. Receive a PaymentReceipt confirming the transaction");
380    println!();
381
382    AgentBuilder::from_file("examples/ap2_merchant.toml")?
383        .with_handler(MerchantHandler)
384        .with_storage(InMemoryTaskStorage::new())
385        .build()?
386        .run()
387        .await
388        .map_err(|e| BuildError::RuntimeError(e.to_string()))?;
389
390    Ok(())
391}