1use serde_json::Value;
59
60use crate::catalog::Catalog;
61use crate::constants::{A2UI_RECOVERY_ACTIVITY_TYPE, MAX_A2UI_ATTEMPTS};
62use crate::error::{Error, Result, ValidationErrors};
63use crate::message::{AgentMessage, AgentPayload, Component};
64use crate::toolkit::parser::parse_response;
65use crate::toolkit::prompt::augment_prompt_with_errors;
66use crate::validate::{ValidateOptions, ValidationError, Validator};
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct RecoveryOptions {
71 pub max_attempts: u32,
73 pub validate: ValidateOptions,
75}
76
77impl Default for RecoveryOptions {
78 fn default() -> Self {
79 Self {
80 max_attempts: MAX_A2UI_ATTEMPTS,
81 validate: ValidateOptions::full_surface(),
82 }
83 }
84}
85
86impl RecoveryOptions {
87 pub fn for_update() -> Self {
89 Self {
90 validate: ValidateOptions::incremental_update(),
91 ..Self::default()
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct RecoveryActivity {
99 pub activity_type: &'static str,
101 pub attempt: u32,
103 pub max_attempts: u32,
105 pub status: RecoveryStatus,
107 pub message: String,
109 pub errors: Vec<ValidationError>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum RecoveryStatus {
116 Started,
118 Succeeded,
120 Retrying,
122 Failed,
124}
125
126impl RecoveryStatus {
127 pub fn as_str(self) -> &'static str {
129 match self {
130 RecoveryStatus::Started => "started",
131 RecoveryStatus::Succeeded => "succeeded",
132 RecoveryStatus::Retrying => "retrying",
133 RecoveryStatus::Failed => "failed",
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq)]
140pub struct RecoveredSurface {
141 pub operations: Vec<AgentMessage>,
143 pub components: Vec<Component>,
145 pub data_model: Value,
147 pub text: String,
149 pub attempts: u32,
151}
152
153pub fn generate_with_recovery(
166 prompt: &str,
167 catalog: &Catalog,
168 options: &RecoveryOptions,
169 mut generate: impl FnMut(&str, u32) -> Result<String>,
170 mut on_activity: impl FnMut(&RecoveryActivity),
171) -> Result<RecoveredSurface> {
172 let max_attempts = options.max_attempts.max(1);
173 let validator = Validator::with_options(catalog, options.validate.clone());
174 let mut errors: Vec<ValidationError> = Vec::new();
175
176 for attempt in 1..=max_attempts {
177 let attempt_prompt = augment_prompt_with_errors(prompt, &errors);
178 on_activity(&RecoveryActivity {
179 activity_type: A2UI_RECOVERY_ACTIVITY_TYPE,
180 attempt,
181 max_attempts,
182 status: RecoveryStatus::Started,
183 message: if attempt == 1 {
184 "Generating the A2UI surface.".to_string()
185 } else {
186 format!(
187 "Retrying the A2UI surface after {} validation error(s).",
188 errors.len()
189 )
190 },
191 errors: Vec::new(),
192 });
193
194 let response = generate(&attempt_prompt, attempt)?;
195
196 match interpret(&response, &validator) {
197 Ok(mut surface) => {
198 surface.attempts = attempt;
199 on_activity(&RecoveryActivity {
200 activity_type: A2UI_RECOVERY_ACTIVITY_TYPE,
201 attempt,
202 max_attempts,
203 status: RecoveryStatus::Succeeded,
204 message: format!(
205 "A2UI surface validated on attempt {attempt} with {} component(s).",
206 surface.components.len()
207 ),
208 errors: Vec::new(),
209 });
210 return Ok(surface);
211 }
212 Err(attempt_errors) => {
213 errors = attempt_errors;
214 let last = attempt == max_attempts;
215 on_activity(&RecoveryActivity {
216 activity_type: A2UI_RECOVERY_ACTIVITY_TYPE,
217 attempt,
218 max_attempts,
219 status: if last {
220 RecoveryStatus::Failed
221 } else {
222 RecoveryStatus::Retrying
223 },
224 message: if last {
225 format!(
226 "Gave up after {max_attempts} attempt(s); {} error(s) remain.",
227 errors.len()
228 )
229 } else {
230 format!(
231 "Attempt {attempt} produced {} validation error(s); retrying.",
232 errors.len()
233 )
234 },
235 errors: errors.clone(),
236 });
237 }
238 }
239 }
240
241 Err(Error::RecoveryExhausted {
242 attempts: max_attempts,
243 last: ValidationErrors(errors),
244 })
245}
246
247fn interpret(
260 response: &str,
261 validator: &Validator<'_>,
262) -> std::result::Result<RecoveredSurface, Vec<ValidationError>> {
263 let parts = match parse_response(response) {
264 Ok(parts) => parts,
265 Err(error) => {
266 return Err(vec![ValidationError::new(
267 crate::validate::ErrorCode::EmptyComponents,
268 "response",
269 format!(
270 "{error} Return the A2UI messages as a JSON array wrapped in the required \
271 tags."
272 ),
273 )]);
274 }
275 };
276
277 let mut operations: Vec<AgentMessage> = Vec::new();
278 let mut text_parts: Vec<String> = Vec::new();
279 for part in &parts {
280 if !part.text.is_empty() {
281 text_parts.push(part.text.clone());
282 }
283 let Some(messages) = &part.a2ui else { continue };
284 for message in messages {
285 match serde_json::from_value::<AgentMessage>(message.clone()) {
286 Ok(operation) => operations.push(operation),
287 Err(error) => {
288 return Err(vec![ValidationError::new(
289 crate::validate::ErrorCode::EmptyComponents,
290 "response",
291 format!(
292 "A message did not match any A2UI envelope ({error}). Each message \
293 must be an object with a 'version' and exactly one of createSurface, \
294 updateComponents, updateDataModel or deleteSurface."
295 ),
296 )]);
297 }
298 }
299 }
300 }
301
302 let report = validator.validate_messages(&operations);
303 if !report.is_valid() {
304 return Err(report.errors);
305 }
306
307 let mut components: Vec<Component> = Vec::new();
308 let mut data_model = Value::Null;
309 for operation in &operations {
310 match &operation.payload {
311 AgentPayload::UpdateComponents(update) => {
312 components.extend(update.components.iter().cloned());
313 }
314 AgentPayload::UpdateDataModel(update) => {
315 let _ = update.apply(&mut data_model);
316 }
317 _ => {}
318 }
319 }
320
321 Ok(RecoveredSurface {
322 operations,
323 components,
324 data_model,
325 text: text_parts.join("\n"),
326 attempts: 1,
327 })
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use serde_json::json;
334
335 fn wrap(components: Value) -> String {
336 format!(
337 "<a2ui-json>[{{\"version\":\"v0.9\",\"createSurface\":{{\"surfaceId\":\"s\",\
338 \"catalogId\":\"c\"}}}},{{\"version\":\"v0.9\",\"updateComponents\":\
339 {{\"surfaceId\":\"s\",\"components\":{components}}}}}]</a2ui-json>"
340 )
341 }
342
343 fn good() -> String {
344 wrap(json!([{"id": "root", "component": "Text", "text": "hi"}]))
345 }
346
347 fn bad() -> String {
348 wrap(json!([{"id": "root", "component": "Card", "child": "nope"}]))
349 }
350
351 #[test]
352 fn a_clean_first_attempt_does_not_retry() {
353 let catalog = Catalog::basic();
354 let mut calls = 0;
355 let mut activities = Vec::new();
356 let surface = generate_with_recovery(
357 "prompt",
358 &catalog,
359 &RecoveryOptions::default(),
360 |_, _| {
361 calls += 1;
362 Ok(good())
363 },
364 |activity| activities.push(activity.clone()),
365 )
366 .unwrap();
367
368 assert_eq!(calls, 1);
369 assert_eq!(surface.attempts, 1);
370 assert_eq!(surface.components.len(), 1);
371 assert_eq!(surface.operations.len(), 2);
372 assert_eq!(
373 activities.iter().map(|a| a.status).collect::<Vec<_>>(),
374 vec![RecoveryStatus::Started, RecoveryStatus::Succeeded]
375 );
376 assert!(
377 activities
378 .iter()
379 .all(|a| a.activity_type == A2UI_RECOVERY_ACTIVITY_TYPE)
380 );
381 }
382
383 #[test]
384 fn a_failed_attempt_puts_the_errors_in_the_next_prompt() {
385 let catalog = Catalog::basic();
386 let mut prompts = Vec::new();
387 let mut calls = 0;
388 let surface = generate_with_recovery(
389 "base prompt",
390 &catalog,
391 &RecoveryOptions::default(),
392 |prompt, attempt| {
393 prompts.push(prompt.to_string());
394 calls += 1;
395 assert_eq!(attempt, calls);
396 Ok(if calls == 1 { bad() } else { good() })
397 },
398 |_| {},
399 )
400 .unwrap();
401
402 assert_eq!(surface.attempts, 2);
403 assert_eq!(prompts.len(), 2);
404 assert!(!prompts[0].contains("Correction required"));
405 assert!(prompts[1].contains("Correction required"));
406 assert!(prompts[1].contains("unresolved_child"));
407 assert!(prompts[1].contains("components[0].child"));
408 }
409
410 #[test]
411 fn three_failures_exhaust_the_loop_and_report_the_last_errors() {
412 let catalog = Catalog::basic();
413 let mut calls = 0;
414 let mut activities = Vec::new();
415 let error = generate_with_recovery(
416 "prompt",
417 &catalog,
418 &RecoveryOptions::default(),
419 |_, _| {
420 calls += 1;
421 Ok(bad())
422 },
423 |activity| activities.push(activity.clone()),
424 )
425 .unwrap_err();
426
427 assert_eq!(calls, MAX_A2UI_ATTEMPTS);
428 let Error::RecoveryExhausted { attempts, last } = error else {
429 panic!("expected RecoveryExhausted");
430 };
431 assert_eq!(attempts, MAX_A2UI_ATTEMPTS);
432 assert!(last.to_string().contains("unresolved_child"));
433
434 let statuses: Vec<_> = activities.iter().map(|a| a.status).collect();
435 assert_eq!(
436 statuses,
437 vec![
438 RecoveryStatus::Started,
439 RecoveryStatus::Retrying,
440 RecoveryStatus::Started,
441 RecoveryStatus::Retrying,
442 RecoveryStatus::Started,
443 RecoveryStatus::Failed,
444 ]
445 );
446 }
447
448 #[test]
449 fn unparseable_output_is_retried_rather_than_raised() {
450 let catalog = Catalog::basic();
451 let mut calls = 0;
452 let surface = generate_with_recovery(
453 "prompt",
454 &catalog,
455 &RecoveryOptions::default(),
456 |_, _| {
457 calls += 1;
458 Ok(if calls == 1 {
459 "I'm afraid I can't do that.".to_string()
460 } else {
461 good()
462 })
463 },
464 |_| {},
465 )
466 .unwrap();
467 assert_eq!(surface.attempts, 2);
468 }
469
470 #[test]
471 fn a_generator_error_stops_the_loop_immediately() {
472 let catalog = Catalog::basic();
473 let mut calls = 0;
474 let error = generate_with_recovery(
475 "prompt",
476 &catalog,
477 &RecoveryOptions::default(),
478 |_, _| {
479 calls += 1;
480 Err(Error::parse("model is offline"))
481 },
482 |_| {},
483 )
484 .unwrap_err();
485 assert_eq!(calls, 1);
486 assert!(matches!(error, Error::Parse(_)));
487 }
488
489 #[test]
490 fn conversational_text_and_data_survive_the_loop() {
491 let catalog = Catalog::basic();
492 let response = "Here is your card.\n<a2ui-json>[{\"version\":\"v0.9\",\"createSurface\":\
493 {\"surfaceId\":\"s\",\"catalogId\":\"c\"}},{\"version\":\"v0.9\",\
494 \"updateComponents\":{\"surfaceId\":\"s\",\"components\":[{\"id\":\"root\",\
495 \"component\":\"Text\",\"text\":{\"path\":\"/name\"}}]}},{\"version\":\
496 \"v0.9\",\"updateDataModel\":{\"surfaceId\":\"s\",\"path\":\"/name\",\
497 \"value\":\"Ada\"}}]</a2ui-json>\nAnything else?";
498 let surface = generate_with_recovery(
499 "prompt",
500 &catalog,
501 &RecoveryOptions::default(),
502 |_, _| Ok(response.to_string()),
503 |_| {},
504 )
505 .unwrap();
506 assert_eq!(surface.text, "Here is your card.\nAnything else?");
507 assert_eq!(surface.data_model, json!({"name": "Ada"}));
508 }
509
510 #[test]
511 fn update_options_accept_an_incremental_payload() {
512 let catalog = Catalog::basic();
513 let response = "<a2ui-json>[{\"version\":\"v0.9\",\"updateComponents\":\
514 {\"surfaceId\":\"s\",\"components\":[{\"id\":\"label\",\
515 \"component\":\"Text\",\"text\":\"updated\"}]}}]</a2ui-json>";
516 let surface = generate_with_recovery(
517 "prompt",
518 &catalog,
519 &RecoveryOptions::for_update(),
520 |_, _| Ok(response.to_string()),
521 |_| {},
522 )
523 .unwrap();
524 assert_eq!(surface.components.len(), 1);
525 assert!(
526 !surface
527 .operations
528 .iter()
529 .any(|op| matches!(op.payload, AgentPayload::CreateSurface(_)))
530 );
531 }
532
533 #[test]
534 fn max_attempts_is_honoured_and_never_zero() {
535 let catalog = Catalog::basic();
536 let mut calls = 0;
537 let options = RecoveryOptions {
538 max_attempts: 0,
539 ..RecoveryOptions::default()
540 };
541 let _ = generate_with_recovery(
542 "prompt",
543 &catalog,
544 &options,
545 |_, _| {
546 calls += 1;
547 Ok(bad())
548 },
549 |_| {},
550 );
551 assert_eq!(calls, 1, "a zero budget must still make one attempt");
552 }
553
554 #[test]
555 fn statuses_have_stable_wire_strings() {
556 assert_eq!(RecoveryStatus::Started.as_str(), "started");
557 assert_eq!(RecoveryStatus::Succeeded.as_str(), "succeeded");
558 assert_eq!(RecoveryStatus::Retrying.as_str(), "retrying");
559 assert_eq!(RecoveryStatus::Failed.as_str(), "failed");
560 }
561}