//! Structured fallback deliverables for failed or stuck jobs. //! //! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures //! what was accomplished before the failure: partial results, action statistics, //! cost, and timing. This gives users visibility into terminal jobs instead of //! just an error string. //! //! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]` //! and surfaced through the `job_status` tool. use serde::{Deserialize, Serialize}; use crate::context::memory::Memory; use crate::context::state::JobContext; /// Structured summary of a failed or stuck job. /// /// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails /// or is marked stuck. Surfaced through the `job_status` tool. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FallbackDeliverable { /// True if at least one action succeeded before failure. pub partial: bool, /// Why the job failed. pub failure_reason: String, /// Last action taken before failure. pub last_action: Option, /// Aggregate action statistics. pub action_stats: ActionStats, /// Total tokens consumed. pub tokens_used: u64, /// Total cost incurred (decimal as string for JSON safety). pub cost: String, /// Wall-clock elapsed time in seconds. pub elapsed_secs: f64, /// Number of self-repair attempts. pub repair_attempts: u32, } /// Summary of the last action taken before failure. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LastAction { pub tool_name: String, /// Truncated to 200 bytes (UTF-8 safe). pub output_preview: String, pub success: bool, } /// Aggregate action counts. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionStats { pub total: u32, pub successful: u32, pub failed: u32, } impl FallbackDeliverable { /// Build a fallback deliverable from a job context and its memory. pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self { let successful = memory.successful_actions() as u32; let failed = memory.failed_actions() as u32; let total = memory.actions.len() as u32; let last_action = memory.last_action().map(|a| { // Use sanitized output to avoid leaking secrets through the fallback API surface. // For failed actions (no sanitized output), fall back to the error message. // Borrow the string slice directly when possible to avoid cloning // potentially large outputs just for truncation. let owned_fallback; let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() { match v { serde_json::Value::String(s) => s.as_str(), other => { owned_fallback = serde_json::to_string(other).unwrap_or_default(); &owned_fallback } } } else if let Some(ref err) = a.error { err.as_str() } else { "" }; let preview = truncate_str(preview_str, 200); LastAction { tool_name: a.tool_name.clone(), output_preview: preview.to_string(), success: a.success, } }); let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64()); Self { partial: successful > 0, failure_reason: truncate_str(reason, 1000).to_string(), last_action, action_stats: ActionStats { total, successful, failed, }, tokens_used: ctx.total_tokens_used, cost: ctx.actual_cost.to_string(), elapsed_secs, repair_attempts: ctx.repair_attempts, } } } /// Truncate a string to at most `max_len` bytes on a char boundary. fn truncate_str(s: &str, max_len: usize) -> &str { &s[..crate::util::floor_char_boundary(s, max_len)] } #[cfg(test)] mod tests { use super::*; use crate::context::memory::Memory; use crate::context::state::JobContext; use chrono::{Duration, Utc}; use rust_decimal::Decimal; use std::time::Duration as StdDuration; #[test] fn test_fallback_zero_actions() { let ctx = JobContext::new("Test", "Empty job"); let memory = Memory::new(ctx.job_id); let fb = FallbackDeliverable::build(&ctx, &memory, "timed out"); assert!(!fb.partial); // safety: test assert_eq!(fb.failure_reason, "timed out"); // safety: test assert!(fb.last_action.is_none()); // safety: test assert_eq!(fb.action_stats.total, 0); // safety: test assert_eq!(fb.action_stats.successful, 0); // safety: test assert_eq!(fb.action_stats.failed, 0); // safety: test assert_eq!(fb.tokens_used, 0); // safety: test assert_eq!(fb.cost, "0"); // safety: test assert_eq!(fb.repair_attempts, 0); // safety: test } #[test] fn test_fallback_mixed_actions() { let mut ctx = JobContext::new("Test", "Mixed job"); ctx.total_tokens_used = 5000; ctx.actual_cost = Decimal::new(42, 2); // 0.42 ctx.repair_attempts = 1; let mut memory = Memory::new(ctx.job_id); // 3 successes for _ in 0..3 { let action = memory .create_action("tool_a", serde_json::json!({})) .succeed( Some("output".to_string()), serde_json::json!({}), StdDuration::from_secs(1), ); memory.record_action(action); } // 2 failures for _ in 0..2 { let action = memory .create_action("tool_b", serde_json::json!({})) .fail("broke", StdDuration::from_secs(1)); memory.record_action(action); } let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations"); assert!(fb.partial); // safety: test assert_eq!(fb.action_stats.total, 5); // safety: test assert_eq!(fb.action_stats.successful, 3); // safety: test assert_eq!(fb.action_stats.failed, 2); // safety: test assert_eq!(fb.tokens_used, 5000); // safety: test assert_eq!(fb.cost, "0.42"); // safety: test assert_eq!(fb.repair_attempts, 1); // safety: test assert!(fb.last_action.is_some()); // safety: test let la = fb.last_action.unwrap(); // safety: test assert_eq!(la.tool_name, "tool_b"); // safety: test assert!(!la.success); // safety: test // Failed actions should surface the error message as the output preview assert_eq!(la.output_preview, "broke"); // safety: test } #[test] fn test_fallback_failed_action_shows_error() { let ctx = JobContext::new("Test", "Error preview"); let mut memory = Memory::new(ctx.job_id); let action = memory .create_action("broken_tool", serde_json::json!({})) .fail("connection timed out after 30s", StdDuration::from_secs(30)); memory.record_action(action); let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure"); let la = fb.last_action.unwrap(); // safety: test assert!(!la.success); // safety: test assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test } #[test] fn test_fallback_last_action_truncation() { let ctx = JobContext::new("Test", "Truncation"); let mut memory = Memory::new(ctx.job_id); let long_output = "x".repeat(500); let action = memory .create_action("tool_c", serde_json::json!({})) .succeed( Some(long_output.clone()), serde_json::Value::String(long_output), StdDuration::from_secs(1), ); memory.record_action(action); let fb = FallbackDeliverable::build(&ctx, &memory, "failed"); let la = fb.last_action.unwrap(); // safety: test assert!(la.output_preview.len() <= 200); // safety: test assert!(!la.output_preview.is_empty()); // safety: test } #[test] fn test_fallback_uses_sanitized_output() { let ctx = JobContext::new("Test", "Sanitized"); let mut memory = Memory::new(ctx.job_id); let action = memory .create_action("tool_d", serde_json::json!({})) .succeed( Some("[REDACTED]".to_string()), serde_json::json!({"api_key": "sk-secret-key-12345"}), StdDuration::from_secs(1), ); memory.record_action(action); let fb = FallbackDeliverable::build(&ctx, &memory, "failed"); let la = fb.last_action.unwrap(); // safety: test // Must use sanitized output, not raw assert!(!la.output_preview.contains("sk-secret")); // safety: test assert!(la.output_preview.contains("REDACTED")); // safety: test } #[test] fn test_fallback_elapsed_time() { let mut ctx = JobContext::new("Test", "Timing"); let now = Utc::now(); ctx.started_at = Some(now - Duration::seconds(10)); ctx.completed_at = Some(now); let memory = Memory::new(ctx.job_id); let fb = FallbackDeliverable::build(&ctx, &memory, "failed"); // Should be approximately 10 seconds assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test } #[test] fn test_fallback_no_started_at() { let ctx = JobContext::new("Test", "Never started"); let memory = Memory::new(ctx.job_id); let fb = FallbackDeliverable::build(&ctx, &memory, "failed"); assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test } #[test] fn test_fallback_elapsed_time_no_completed_at() { let mut ctx = JobContext::new("Test", "Still running"); ctx.started_at = Some(Utc::now() - Duration::seconds(5)); // completed_at is None — should use Utc::now() as fallback let memory = Memory::new(ctx.job_id); let fb = FallbackDeliverable::build(&ctx, &memory, "stuck"); // Should be approximately 5 seconds (using now as end time) assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test } #[test] fn test_fallback_failure_reason_truncation() { let ctx = JobContext::new("Test", "Long reason"); let memory = Memory::new(ctx.job_id); let long_reason = "x".repeat(5000); let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason); assert!(fb.failure_reason.len() <= 1000); // safety: test assert!(!fb.failure_reason.is_empty()); // safety: test } #[test] fn test_truncate_str_ascii() { assert_eq!(truncate_str("hello", 10), "hello"); // safety: test assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test } #[test] fn test_truncate_str_unicode() { // "é" is 2 bytes in UTF-8 let s = "café"; assert_eq!(truncate_str(s, 10), "café"); // safety: test // Truncating at 4 would split "é", should back up to 3 assert_eq!(truncate_str(s, 4), "caf"); // safety: test } #[test] fn test_fallback_serialization() { let ctx = JobContext::new("Test", "Serialize"); let memory = Memory::new(ctx.job_id); let fb = FallbackDeliverable::build(&ctx, &memory, "test error"); // Should serialize to JSON and back without error let json = serde_json::to_value(&fb).unwrap(); // safety: test let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test assert_eq!(deserialized.failure_reason, "test error"); // safety: test } }