Skip to main content

brainos_delegate/
escalate.rs

1//! Escalation — turn a delegate failure into a typed decision the
2//! orchestrator can act on (retry once, try a fallback agent, escalate
3//! to the human).
4
5use std::sync::Arc;
6
7use crate::registry::AgentRegistry;
8use crate::traits::{AgentDelegate, AgentError, AgentResult, AgentTask, AgentTaskStatus};
9
10/// What should happen after a delegate call returns.
11#[derive(Debug, Clone)]
12pub enum EscalationOutcome {
13    /// The delegate succeeded — carry the result upstream.
14    Succeeded(AgentResult),
15    /// The delegate failed; the orchestrator should pause the task and
16    /// surface `reason` to the user via the channel router.
17    EscalateToHuman { reason: String },
18    /// The delegate failed on a retryable error; a fallback delegate
19    /// took over and succeeded. `via` is the fallback's name.
20    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    /// Unwrap to an `AgentResult` when the outcome represents a finished
32    /// run (either on the primary or a fallback). Returns `None` for
33    /// `EscalateToHuman`.
34    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/// Caller-supplied policy controlling how failures are handled.
44#[derive(Debug, Clone)]
45pub struct EscalationPolicy {
46    /// Ordered fallback delegate names tried after the primary fails on
47    /// a retryable error. Empty means "escalate immediately".
48    pub fallbacks: Vec<String>,
49    /// Whether timeouts should trigger fallback attempts. Defaults to
50    /// `true` — a hung primary is often recoverable via a different
51    /// agent. Set `false` for tasks where retry cost is prohibitive.
52    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
85/// Run a delegate against the registry, honouring `policy`. `AgentResult`s
86/// with non-success status are counted as failures (e.g. subprocess
87/// delegates surface exit != 0 via `Failed` rather than `Err`).
88pub 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    /// Test delegate that always returns the configured status.
160    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    /// Test delegate that always errors with the configured variant.
194    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, &reg, 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, &reg, task(), &policy).await;
249        // Parse error isn't retryable — fallback should NOT be attempted.
250        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, &reg, 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, &reg, 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, &reg, 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        // A primary that reports non-success via `status` (rather than
314        // an AgentError) is treated the same as a retryable failure —
315        // fallbacks are tried in order.
316        let policy = EscalationPolicy::default().with_fallbacks(["fallback"]);
317        let out = run_with_escalation(primary, &reg, task(), &policy).await;
318        assert!(matches!(out, EscalationOutcome::Recovered { via, .. } if via == "fallback"));
319    }
320}