1use 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
31struct Product {
33 sku: &'static str,
34 name: &'static str,
35 price: f64,
36 description: &'static str,
37}
38
39const 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#[derive(Clone)]
69struct MerchantHandler;
70
71impl MerchantHandler {
72 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 fn match_products(&self, description: &str) -> Vec<&Product> {
142 let desc_lower = description.to_lowercase();
143
144 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 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 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 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 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 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}