1use std::sync::Arc;
6
7use crate::registry::AgentRegistry;
8use crate::traits::{AgentDelegate, AgentError, AgentResult, AgentTask, AgentTaskStatus};
9
10#[derive(Debug, Clone)]
12pub enum EscalationOutcome {
13 Succeeded(AgentResult),
15 EscalateToHuman { reason: String },
18 Recovered { via: String, result: AgentResult },
21}
22
23impl EscalationOutcome {
24 pub fn succeeded(&self) -> bool {
25 matches!(
26 self,
27 EscalationOutcome::Succeeded(_) | EscalationOutcome::Recovered { .. }
28 )
29 }
30
31 pub fn into_result(self) -> Option<AgentResult> {
35 match self {
36 EscalationOutcome::Succeeded(r) => Some(r),
37 EscalationOutcome::Recovered { result, .. } => Some(result),
38 EscalationOutcome::EscalateToHuman { .. } => None,
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct EscalationPolicy {
46 pub fallbacks: Vec<String>,
49 pub retry_on_timeout: bool,
53}
54
55impl Default for EscalationPolicy {
56 fn default() -> Self {
57 Self {
58 fallbacks: Vec::new(),
59 retry_on_timeout: true,
60 }
61 }
62}
63
64impl EscalationPolicy {
65 pub fn with_fallbacks<I, S>(mut self, names: I) -> Self
66 where
67 I: IntoIterator<Item = S>,
68 S: Into<String>,
69 {
70 self.fallbacks = names.into_iter().map(Into::into).collect();
71 self
72 }
73}
74
75fn is_retryable(err: &AgentError) -> bool {
76 matches!(
77 err,
78 AgentError::Timeout { .. }
79 | AgentError::Launch(_)
80 | AgentError::Io(_)
81 | AgentError::NonZeroExit { .. }
82 )
83}
84
85pub async fn run_with_escalation(
89 primary: Arc<dyn AgentDelegate>,
90 registry: &AgentRegistry,
91 task: AgentTask,
92 policy: &EscalationPolicy,
93) -> EscalationOutcome {
94 let primary_name = primary.name().to_string();
95 let primary_err = match primary.delegate(task.clone()).await {
96 Ok(result) if result.status.is_success() => {
97 return EscalationOutcome::Succeeded(result);
98 }
99 Ok(result) => format!(
100 "delegate '{primary_name}' returned status {:?}: {}",
101 result.status, result.summary
102 ),
103 Err(e) => {
104 if !is_retryable(&e)
105 || (matches!(e, AgentError::Timeout { .. }) && !policy.retry_on_timeout)
106 {
107 return EscalationOutcome::EscalateToHuman {
108 reason: format!("delegate '{primary_name}' failed: {e}"),
109 };
110 }
111 format!("delegate '{primary_name}' failed: {e}")
112 }
113 };
114
115 for fallback in &policy.fallbacks {
116 if fallback == &primary_name {
117 continue;
118 }
119 let candidate = match registry.get(fallback) {
120 Ok(c) => c,
121 Err(_) => {
122 tracing::warn!(fallback = %fallback, "Fallback delegate not registered");
123 continue;
124 }
125 };
126 match candidate.delegate(task.clone()).await {
127 Ok(result) if result.status == AgentTaskStatus::Succeeded => {
128 tracing::info!(
129 primary = %primary_name,
130 fallback = %fallback,
131 "Delegate recovered via fallback"
132 );
133 return EscalationOutcome::Recovered {
134 via: fallback.clone(),
135 result,
136 };
137 }
138 Ok(r) => tracing::warn!(
139 fallback = %fallback,
140 status = ?r.status,
141 "Fallback delegate returned non-success status"
142 ),
143 Err(e) => tracing::warn!(fallback = %fallback, error = %e, "Fallback delegate failed"),
144 }
145 }
146
147 EscalationOutcome::EscalateToHuman {
148 reason: format!("{primary_err}; no fallback recovered"),
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::traits::{AgentCapabilities, AgentTaskStatus};
156 use async_trait::async_trait;
157 use chrono::Utc;
158
159 struct FixedAgent {
161 name: String,
162 succeed: bool,
163 }
164
165 #[async_trait]
166 impl AgentDelegate for FixedAgent {
167 fn name(&self) -> &str {
168 &self.name
169 }
170 fn capabilities(&self) -> AgentCapabilities {
171 AgentCapabilities::default()
172 }
173 async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError> {
174 let now = Utc::now();
175 Ok(AgentResult {
176 task_id: task.id,
177 status: if self.succeed {
178 AgentTaskStatus::Succeeded
179 } else {
180 AgentTaskStatus::Failed
181 },
182 summary: format!("{} ran", self.name),
183 artifacts: vec![],
184 stdout: String::new(),
185 stderr: String::new(),
186 exit_code: Some(if self.succeed { 0 } else { 1 }),
187 started_at: now,
188 completed_at: now,
189 })
190 }
191 }
192
193 struct ErroringAgent {
195 name: String,
196 err_kind: ErrKind,
197 }
198 enum ErrKind {
199 Timeout,
200 Launch,
201 Parse,
202 }
203
204 #[async_trait]
205 impl AgentDelegate for ErroringAgent {
206 fn name(&self) -> &str {
207 &self.name
208 }
209 async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError> {
210 Err(match self.err_kind {
211 ErrKind::Timeout => AgentError::Timeout {
212 task_id: task.id,
213 secs: 1,
214 },
215 ErrKind::Launch => AgentError::Launch("boom".to_string()),
216 ErrKind::Parse => AgentError::Parse("bad output".to_string()),
217 })
218 }
219 }
220
221 fn task() -> AgentTask {
222 AgentTask::new("do a thing")
223 }
224
225 #[tokio::test]
226 async fn success_returns_succeeded() {
227 let agent: Arc<dyn AgentDelegate> = Arc::new(FixedAgent {
228 name: "a".to_string(),
229 succeed: true,
230 });
231 let reg = AgentRegistry::new();
232 let out = run_with_escalation(agent, ®, task(), &EscalationPolicy::default()).await;
233 assert!(matches!(out, EscalationOutcome::Succeeded(_)));
234 }
235
236 #[tokio::test]
237 async fn non_retryable_error_escalates_immediately() {
238 let agent: Arc<dyn AgentDelegate> = Arc::new(ErroringAgent {
239 name: "a".to_string(),
240 err_kind: ErrKind::Parse,
241 });
242 let mut reg = AgentRegistry::new();
243 reg.register(Arc::new(FixedAgent {
244 name: "b".to_string(),
245 succeed: true,
246 }));
247 let policy = EscalationPolicy::default().with_fallbacks(["b"]);
248 let out = run_with_escalation(agent, ®, task(), &policy).await;
249 assert!(matches!(out, EscalationOutcome::EscalateToHuman { .. }));
251 }
252
253 #[tokio::test]
254 async fn retryable_error_tries_fallback() {
255 let primary: Arc<dyn AgentDelegate> = Arc::new(ErroringAgent {
256 name: "primary".to_string(),
257 err_kind: ErrKind::Launch,
258 });
259 let mut reg = AgentRegistry::new();
260 reg.register(Arc::new(FixedAgent {
261 name: "fallback".to_string(),
262 succeed: true,
263 }));
264 let policy = EscalationPolicy::default().with_fallbacks(["fallback"]);
265 let out = run_with_escalation(primary, ®, task(), &policy).await;
266 assert!(matches!(out, EscalationOutcome::Recovered { via, .. } if via == "fallback"));
267 }
268
269 #[tokio::test]
270 async fn timeout_respects_retry_flag() {
271 let primary: Arc<dyn AgentDelegate> = Arc::new(ErroringAgent {
272 name: "primary".to_string(),
273 err_kind: ErrKind::Timeout,
274 });
275 let mut reg = AgentRegistry::new();
276 reg.register(Arc::new(FixedAgent {
277 name: "fallback".to_string(),
278 succeed: true,
279 }));
280 let mut policy = EscalationPolicy::default().with_fallbacks(["fallback"]);
281 policy.retry_on_timeout = false;
282 let out = run_with_escalation(primary, ®, task(), &policy).await;
283 assert!(matches!(out, EscalationOutcome::EscalateToHuman { .. }));
284 }
285
286 #[tokio::test]
287 async fn all_fallbacks_fail_escalates() {
288 let primary: Arc<dyn AgentDelegate> = Arc::new(ErroringAgent {
289 name: "primary".to_string(),
290 err_kind: ErrKind::Launch,
291 });
292 let mut reg = AgentRegistry::new();
293 reg.register(Arc::new(ErroringAgent {
294 name: "also-broken".to_string(),
295 err_kind: ErrKind::Launch,
296 }));
297 let policy = EscalationPolicy::default().with_fallbacks(["also-broken"]);
298 let out = run_with_escalation(primary, ®, task(), &policy).await;
299 assert!(matches!(out, EscalationOutcome::EscalateToHuman { .. }));
300 }
301
302 #[tokio::test]
303 async fn non_success_status_counts_as_failure_and_triggers_fallback() {
304 let primary: Arc<dyn AgentDelegate> = Arc::new(FixedAgent {
305 name: "primary".to_string(),
306 succeed: false,
307 });
308 let mut reg = AgentRegistry::new();
309 reg.register(Arc::new(FixedAgent {
310 name: "fallback".to_string(),
311 succeed: true,
312 }));
313 let policy = EscalationPolicy::default().with_fallbacks(["fallback"]);
317 let out = run_with_escalation(primary, ®, task(), &policy).await;
318 assert!(matches!(out, EscalationOutcome::Recovered { via, .. } if via == "fallback"));
319 }
320}