Skip to main content

car_server_core/coder/
router.rs

1//! Engine selection: native CAR loop vs. delegation to an external CLI.
2//!
3//! Resolution order: an explicit choice always wins; `Auto` assesses the
4//! intent with `car-inference`'s [`TaskComplexity`] heuristics and delegates
5//! to the preferred ready external CLI only for `Complex` work, keeping
6//! routine tasks on the native loop. Detection is injected as plain data so
7//! resolution is unit-testable without binaries on `$PATH`.
8
9use car_inference::adaptive_router::TaskComplexity;
10use serde::{Deserialize, Serialize};
11
12/// Which engine performs the coding work.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum EngineChoice {
16    /// Decide per task: complexity assessment + detected external CLIs.
17    Auto,
18    /// CAR's own inference + tool loop.
19    Native,
20    /// One session of a detected external agentic CLI (`claude-code`,
21    /// `codex`, `gemini`).
22    External(String),
23    /// Foreman (#274): decompose the intent, farm subtasks to the external
24    /// CLI in parallel worktrees, gate each patch + the integrated union,
25    /// then land the verified union in the session worktree. Declines to
26    /// single-session/native when the plan has no parallelism.
27    Foreman(String),
28}
29
30impl EngineChoice {
31    /// Parse the RPC/CLI form: `auto` | `native` | `external[:agent_id]` |
32    /// `foreman[:agent_id]`.
33    pub fn parse(s: &str) -> Result<Self, String> {
34        match s.trim() {
35            "auto" | "" => Ok(Self::Auto),
36            "native" => Ok(Self::Native),
37            "external" => Ok(Self::External(String::new())),
38            "foreman" => Ok(Self::Foreman(String::new())),
39            other => {
40                if let Some(id) = other.strip_prefix("external:") {
41                    if !id.is_empty() {
42                        return Ok(Self::External(id.to_string()));
43                    }
44                }
45                if let Some(id) = other.strip_prefix("foreman:") {
46                    if !id.is_empty() {
47                        return Ok(Self::Foreman(id.to_string()));
48                    }
49                }
50                Err(format!(
51                    "unknown engine '{other}' (expected auto | native | external[:agent_id] | foreman[:agent_id])"
52                ))
53            }
54        }
55    }
56
57    pub fn label(&self) -> String {
58        match self {
59            Self::Auto => "auto".to_string(),
60            Self::Native => "native".to_string(),
61            Self::External(id) if id.is_empty() => "external".to_string(),
62            Self::External(id) => format!("external:{id}"),
63            Self::Foreman(id) if id.is_empty() => "foreman".to_string(),
64            Self::Foreman(id) => format!("foreman:{id}"),
65        }
66    }
67}
68
69/// What resolution needs to know about one detected external CLI.
70#[derive(Debug, Clone)]
71pub struct DetectedAgent {
72    pub id: String,
73    /// Health bucket is `Ready` (authenticated, status command succeeded).
74    pub ready: bool,
75    /// Set when detection proved the binary cannot be executed at all —
76    /// carries the diagnosis. Kept separate from `ready` because the two
77    /// answer different questions, and collapsing them is what made a
78    /// quarantined binary indistinguishable from a signed-out one. Sending
79    /// a user to `codex login` when the login command is itself
80    /// unrunnable is a dead end.
81    pub unusable_reason: Option<String>,
82}
83
84impl DetectedAgent {
85    /// The user-facing explanation for why this agent can't be used, or
86    /// `None` when it is ready. Prefers the executability diagnosis over
87    /// the auth guess.
88    fn unavailable_reason(&self, kind: &str) -> Option<String> {
89        if let Some(why) = &self.unusable_reason {
90            return Some(format!(
91                "{kind} '{}' is installed but cannot be executed: {why}",
92                self.id
93            ));
94        }
95        if !self.ready {
96            return Some(format!(
97                "{kind} '{}' is installed but not ready (not authenticated?)",
98                self.id
99            ));
100        }
101        None
102    }
103}
104
105/// Default delegation preference when several CLIs are ready.
106pub const DEFAULT_PREFERENCE: [&str; 3] = ["claude-code", "codex", "gemini"];
107
108/// A resolved engine (never `Auto`) plus the reason, for the event stream.
109#[derive(Debug, Clone, PartialEq)]
110pub struct ResolvedEngine {
111    pub engine: EngineChoice,
112    pub reason: String,
113}
114
115/// Snapshot the currently-detected external CLIs (with health) into the
116/// plain shape [`resolve_engine`] consumes.
117pub async fn detect_ready_agents() -> Vec<DetectedAgent> {
118    car_external_agents::detect_with_health(false)
119        .await
120        .into_iter()
121        .map(|spec| DetectedAgent {
122            ready: matches!(
123                &spec.health,
124                Some(h) if h.status == car_external_agents::HealthStatus::Ready
125            ),
126            unusable_reason: spec.unusable_reason().map(str::to_string),
127            id: spec.id,
128        })
129        .collect()
130}
131
132/// Pick an agent for a request that named none.
133///
134/// Both branches skip anything [`car_external_agents::auto_selectable`] holds
135/// back. Today that is `mini-swe-agent`, which exists as the `coder-ab`
136/// measurement baseline: without the filter the `or_else` fallback below would
137/// hand a user's real coding task to a control arm on any machine where it is
138/// the only signed-in CLI. An explicitly named agent is unaffected — that path
139/// does not come through here.
140fn first_ready(preference: &[&str], detected: &[DetectedAgent]) -> Option<String> {
141    let usable = |d: &DetectedAgent| d.ready && car_external_agents::auto_selectable(d.id.as_str());
142    preference
143        .iter()
144        .find(|p| detected.iter().any(|d| usable(d) && d.id == **p))
145        .map(|p| p.to_string())
146        // A ready agent outside the preference list still beats nothing.
147        .or_else(|| detected.iter().find(|d| usable(d)).map(|d| d.id.clone()))
148}
149
150/// Resolve the requested engine against the detected CLIs and the intent's
151/// assessed complexity. Errors only when an *explicit* external request can't
152/// be satisfied — `Auto` always resolves (falling back to native).
153pub fn resolve_engine(
154    requested: &EngineChoice,
155    intent: &str,
156    detected: &[DetectedAgent],
157    preference: &[&str],
158) -> Result<ResolvedEngine, String> {
159    match requested {
160        EngineChoice::Native => Ok(ResolvedEngine {
161            engine: EngineChoice::Native,
162            reason: "explicitly requested".into(),
163        }),
164        EngineChoice::External(id) if !id.is_empty() => {
165            let agent = detected
166                .iter()
167                .find(|d| d.id == *id)
168                .ok_or_else(|| format!("external agent '{id}' is not installed"))?;
169            if let Some(why) = agent.unavailable_reason("external agent") {
170                return Err(why);
171            }
172            Ok(ResolvedEngine {
173                engine: EngineChoice::External(id.clone()),
174                reason: "explicitly requested".into(),
175            })
176        }
177        EngineChoice::External(_) => {
178            let id = first_ready(preference, detected).ok_or(
179                "external engine requested but no external agent CLI is installed and ready",
180            )?;
181            Ok(ResolvedEngine {
182                engine: EngineChoice::External(id),
183                reason: "first ready external agent".into(),
184            })
185        }
186        EngineChoice::Foreman(id) if !id.is_empty() => {
187            let agent = detected
188                .iter()
189                .find(|d| d.id == *id)
190                .ok_or_else(|| format!("foreman adapter '{id}' is not installed"))?;
191            if let Some(why) = agent.unavailable_reason("foreman adapter") {
192                return Err(why);
193            }
194            // Naming a measurement baseline outright is meaningful for
195            // `external` — that IS how `coder-ab` runs its control arm — and
196            // meaningless for `foreman`, which decomposes the goal and farms
197            // subtasks through CAR's own orchestration. A baseline run through
198            // that measures CAR's orchestrator, not the baseline, so it is
199            // refused rather than quietly producing a number nobody can read.
200            if !car_external_agents::auto_selectable(id.as_str()) {
201                return Err(format!(
202                    "'{id}' is a measurement baseline, not a Foreman adapter — run it as \
203                     `external:{id}` (see docs/coder-ab-guide.md)"
204                ));
205            }
206            Ok(ResolvedEngine {
207                engine: EngineChoice::Foreman(id.clone()),
208                reason: "explicitly requested".into(),
209            })
210        }
211        EngineChoice::Foreman(_) => {
212            let id = first_ready(preference, detected).ok_or(
213                "foreman engine requested but no external agent CLI is installed and ready",
214            )?;
215            Ok(ResolvedEngine {
216                engine: EngineChoice::Foreman(id),
217                reason: "first ready external agent".into(),
218            })
219        }
220        EngineChoice::Auto => {
221            // `assess` buckets almost any coding intent as `Code` (repair
222            // markers like "fix"/"refactor" dominate), so `Code` alone can't
223            // mean "delegate". Frontier-worthy = genuinely Complex, or a Code
224            // task whose intent is long/multi-step enough that a frontier CLI
225            // is likely to outperform the native loop. The explicit engine
226            // override is the real control; this is a default, not a promise.
227            let complexity = TaskComplexity::assess(intent);
228            let broad_scope = intent.split_whitespace().count() > 120;
229            let frontier_worthy = complexity == TaskComplexity::Complex
230                || (complexity == TaskComplexity::Code && broad_scope);
231            if frontier_worthy {
232                if let Some(id) = first_ready(preference, detected) {
233                    // Foreman-first: its planner self-selects — a plan with
234                    // no parallelism declines to single-session, so trivial
235                    // tasks never pay the farm-out cost.
236                    return Ok(ResolvedEngine {
237                        engine: EngineChoice::Foreman(id),
238                        reason: format!(
239                            "task assessed as {complexity:?} with broad scope and a frontier CLI is ready; \
240                             foreman gates the parallel farm-out"
241                        ),
242                    });
243                }
244            }
245            Ok(ResolvedEngine {
246                engine: EngineChoice::Native,
247                reason: if frontier_worthy {
248                    "task is complex but no external CLI is ready; using native loop".into()
249                } else {
250                    format!("task assessed as {complexity:?}; native loop suffices")
251                },
252            })
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn agents(ready: &[&str], installed_not_ready: &[&str]) -> Vec<DetectedAgent> {
262        ready
263            .iter()
264            .map(|id| DetectedAgent {
265                id: id.to_string(),
266                ready: true,
267                unusable_reason: None,
268            })
269            .chain(installed_not_ready.iter().map(|id| DetectedAgent {
270                id: id.to_string(),
271                ready: false,
272                unusable_reason: None,
273            }))
274            .collect()
275    }
276
277    /// An installed-but-unrunnable CLI must be reported as unrunnable,
278    /// not as an auth problem. Sending the user to `codex login` when
279    /// the login command is itself SIGKILLed is a dead end, and this is
280    /// the path `coder.start --engine codex` takes — the one the
281    /// original Gatekeeper report came from.
282    #[test]
283    fn explicit_engine_reports_unrunnable_over_auth() {
284        let detected = vec![DetectedAgent {
285            id: "codex".to_string(),
286            ready: false,
287            unusable_reason: Some("killed by signal 9".to_string()),
288        }];
289        let err = resolve_engine(
290            &EngineChoice::External("codex".to_string()),
291            "add a test",
292            &detected,
293            &DEFAULT_PREFERENCE,
294        )
295        .unwrap_err();
296        assert!(
297            err.contains("cannot be executed") && err.contains("signal 9"),
298            "want the executability diagnosis, got {err:?}"
299        );
300        assert!(
301            !err.contains("not authenticated"),
302            "must not blame auth for an unrunnable binary, got {err:?}"
303        );
304    }
305
306    // A prompt TaskComplexity::assess reliably buckets as Complex: long,
307    // multi-step, architecture-flavored.
308    fn complex_intent() -> String {
309        format!(
310            "Refactor the authentication architecture across the whole system, design and \
311             implement the migration step by step, then analyze the tradeoffs. {}",
312            "Consider every module and integration in depth. ".repeat(30)
313        )
314    }
315
316    #[test]
317    fn explicit_choice_always_wins() {
318        let detected = agents(&["claude-code"], &[]);
319        let r = resolve_engine(
320            &EngineChoice::Native,
321            &complex_intent(),
322            &detected,
323            &DEFAULT_PREFERENCE,
324        )
325        .unwrap();
326        assert_eq!(r.engine, EngineChoice::Native);
327
328        let r = resolve_engine(
329            &EngineChoice::External("claude-code".into()),
330            "tiny task",
331            &detected,
332            &DEFAULT_PREFERENCE,
333        )
334        .unwrap();
335        assert_eq!(r.engine, EngineChoice::External("claude-code".into()));
336    }
337
338    #[test]
339    fn explicit_external_fails_clearly_when_unavailable() {
340        let err = resolve_engine(
341            &EngineChoice::External("codex".into()),
342            "x",
343            &agents(&[], &["codex"]),
344            &DEFAULT_PREFERENCE,
345        )
346        .unwrap_err();
347        assert!(err.contains("not ready"), "{err}");
348
349        let err = resolve_engine(
350            &EngineChoice::External("codex".into()),
351            "x",
352            &agents(&[], &[]),
353            &DEFAULT_PREFERENCE,
354        )
355        .unwrap_err();
356        assert!(err.contains("not installed"), "{err}");
357    }
358
359    #[test]
360    fn auto_with_no_clis_is_native() {
361        let r = resolve_engine(
362            &EngineChoice::Auto,
363            &complex_intent(),
364            &[],
365            &DEFAULT_PREFERENCE,
366        )
367        .unwrap();
368        assert_eq!(r.engine, EngineChoice::Native);
369        assert!(r.reason.contains("no external CLI"), "{}", r.reason);
370    }
371
372    /// The `coder-ab` baseline must never be picked by a request that named
373    /// no agent — not by `auto`, and not by a bare `external`. It is a control
374    /// arm; routing a user's real task to it would be shipping the control.
375    #[test]
376    fn an_unnamed_request_never_resolves_to_the_measurement_baseline() {
377        let detected = agents(&["mini-swe-agent"], &[]);
378
379        let bare = resolve_engine(
380            &EngineChoice::External(String::new()),
381            &complex_intent(),
382            &detected,
383            &DEFAULT_PREFERENCE,
384        );
385        assert!(
386            bare.is_err(),
387            "bare `external` must not fall back to the baseline: {bare:?}"
388        );
389
390        // `auto` has to resolve *something*, and the honest something is the
391        // native loop — not the control arm.
392        let auto = resolve_engine(
393            &EngineChoice::Auto,
394            &complex_intent(),
395            &detected,
396            &DEFAULT_PREFERENCE,
397        )
398        .unwrap();
399        assert_eq!(auto.engine, EngineChoice::Native, "{}", auto.reason);
400    }
401
402    /// The other half: holding it back from *unnamed* resolution must not make
403    /// it unreachable. `car coder-ab --external mini-swe-agent` is the whole
404    /// reason the adapter exists.
405    #[test]
406    fn naming_the_baseline_outright_still_resolves() {
407        let detected = agents(&["mini-swe-agent"], &[]);
408        let r = resolve_engine(
409            &EngineChoice::External("mini-swe-agent".into()),
410            "fix the failing test",
411            &detected,
412            &DEFAULT_PREFERENCE,
413        )
414        .unwrap();
415        assert_eq!(
416            r.engine,
417            EngineChoice::External("mini-swe-agent".into()),
418            "{}",
419            r.reason
420        );
421    }
422
423    /// `docs/websocket-protocol.md` asserts the baseline is not a Foreman
424    /// adapter. The `first_ready` filter alone does not enforce that — the
425    /// explicit-Foreman branch never consults it — so the claim needs its own
426    /// check or the docs describe a rule the code does not have.
427    #[test]
428    fn the_measurement_baseline_is_refused_as_a_foreman_adapter() {
429        let detected = agents(&["mini-swe-agent"], &[]);
430        let err = resolve_engine(
431            &EngineChoice::Foreman("mini-swe-agent".into()),
432            &complex_intent(),
433            &detected,
434            &DEFAULT_PREFERENCE,
435        )
436        .expect_err("a control arm run through CAR's orchestrator measures the orchestrator");
437        assert!(err.contains("measurement baseline"), "{err}");
438        // The error has to name the thing that DOES work, or it is a dead end.
439        assert!(err.contains("external:mini-swe-agent"), "{err}");
440    }
441
442    /// Positive control on the two above: the same fixture shape with a
443    /// production adapter DOES resolve through the fallback, so the assertions
444    /// are testing the exclusion and not a broken fixture.
445    #[test]
446    fn an_unnamed_request_still_resolves_to_a_production_agent() {
447        // `codex` is in DEFAULT_PREFERENCE; use an id that is not, so this
448        // exercises the same `or_else` fallback the baseline is held out of.
449        let detected = agents(&["some-future-cli"], &[]);
450        let r = resolve_engine(
451            &EngineChoice::External(String::new()),
452            &complex_intent(),
453            &detected,
454            &DEFAULT_PREFERENCE,
455        )
456        .unwrap();
457        assert_eq!(r.engine, EngineChoice::External("some-future-cli".into()));
458    }
459
460    #[test]
461    fn auto_simple_task_stays_native_even_with_clis() {
462        let detected = agents(&["claude-code"], &[]);
463        let r = resolve_engine(
464            &EngineChoice::Auto,
465            "fix typo in README",
466            &detected,
467            &DEFAULT_PREFERENCE,
468        )
469        .unwrap();
470        assert_eq!(r.engine, EngineChoice::Native, "{}", r.reason);
471    }
472
473    #[test]
474    fn auto_complex_task_delegates_foreman_first_in_preference_order() {
475        let detected = agents(&["gemini", "claude-code"], &["codex"]);
476        let r = resolve_engine(
477            &EngineChoice::Auto,
478            &complex_intent(),
479            &detected,
480            &DEFAULT_PREFERENCE,
481        )
482        .unwrap();
483        assert_eq!(
484            r.engine,
485            EngineChoice::Foreman("claude-code".into()),
486            "foreman-first with preference order: {}",
487            r.reason
488        );
489    }
490
491    #[test]
492    fn explicit_foreman_resolves_and_fails_clearly() {
493        let detected = agents(&["codex"], &["claude-code"]);
494        let r = resolve_engine(
495            &EngineChoice::Foreman(String::new()),
496            "x",
497            &detected,
498            &DEFAULT_PREFERENCE,
499        )
500        .unwrap();
501        assert_eq!(r.engine, EngineChoice::Foreman("codex".into()));
502
503        let err = resolve_engine(
504            &EngineChoice::Foreman("claude-code".into()),
505            "x",
506            &detected,
507            &DEFAULT_PREFERENCE,
508        )
509        .unwrap_err();
510        assert!(err.contains("not ready"), "{err}");
511
512        let err = resolve_engine(
513            &EngineChoice::Foreman(String::new()),
514            "x",
515            &agents(&[], &[]),
516            &DEFAULT_PREFERENCE,
517        )
518        .unwrap_err();
519        assert!(err.contains("no external agent"), "{err}");
520    }
521
522    #[test]
523    fn ready_agent_outside_preference_still_selected() {
524        let detected = agents(&["future-cli"], &[]);
525        let r = resolve_engine(
526            &EngineChoice::External(String::new()),
527            "x",
528            &detected,
529            &DEFAULT_PREFERENCE,
530        )
531        .unwrap();
532        assert_eq!(r.engine, EngineChoice::External("future-cli".into()));
533    }
534
535    #[test]
536    fn parse_round_trips() {
537        for (input, expect) in [
538            ("auto", EngineChoice::Auto),
539            ("", EngineChoice::Auto),
540            ("native", EngineChoice::Native),
541            ("external", EngineChoice::External(String::new())),
542            (
543                "external:claude-code",
544                EngineChoice::External("claude-code".into()),
545            ),
546            ("foreman", EngineChoice::Foreman(String::new())),
547            ("foreman:codex", EngineChoice::Foreman("codex".into())),
548        ] {
549            assert_eq!(EngineChoice::parse(input).unwrap(), expect);
550        }
551        assert!(EngineChoice::parse("warp-drive").is_err());
552        assert!(EngineChoice::parse("external:").is_err());
553        assert!(EngineChoice::parse("foreman:").is_err());
554    }
555}